merged main

This commit is contained in:
Ed McConnell
2024-12-13 20:49:33 -05:00
97 changed files with 5295 additions and 921 deletions

1
app/commit.json Normal file
View File

@@ -0,0 +1 @@
{ "commit": "1e04ab38b07e82852626b164890f4a6df1f98cef" }

View File

@@ -28,6 +28,7 @@ interface ArtifactProps {
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId];
@@ -47,6 +48,14 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
if (actions.length !== 0 && artifact.type === 'bundled') {
const finished = !actions.find((action) => action.status !== 'complete');
if (allActionFinished !== finished) {
setAllActionFinished(finished);
}
}
}, [actions]);
return (
@@ -59,6 +68,18 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
{artifact.type == 'bundled' && (
<>
<div className="p-4">
{allActionFinished ? (
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
) : (
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
)}
</div>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
</>
)}
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
@@ -66,7 +87,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{actions.length && (
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
@@ -83,7 +104,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
</AnimatePresence>
</div>
<AnimatePresence>
{showActions && actions.length > 0 && (
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
@@ -92,6 +113,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>

View File

@@ -18,82 +18,6 @@
opacity: 1;
}
.RayContainer {
--gradient-opacity: 0.85;
--ray-gradient: radial-gradient(rgba(83, 196, 255, var(--gradient-opacity)) 0%, rgba(43, 166, 255, 0) 100%);
transition: opacity 0.25s linear;
position: fixed;
inset: 0;
pointer-events: none;
user-select: none;
}
.LightRayOne {
width: 480px;
height: 680px;
transform: rotate(80deg);
top: -540px;
left: 250px;
filter: blur(110px);
position: absolute;
border-radius: 100%;
background: var(--ray-gradient);
}
.LightRayTwo {
width: 110px;
height: 400px;
transform: rotate(-20deg);
top: -280px;
left: 350px;
mix-blend-mode: overlay;
opacity: 0.6;
filter: blur(60px);
position: absolute;
border-radius: 100%;
background: var(--ray-gradient);
}
.LightRayThree {
width: 400px;
height: 370px;
top: -350px;
left: 200px;
mix-blend-mode: overlay;
opacity: 0.6;
filter: blur(21px);
position: absolute;
border-radius: 100%;
background: var(--ray-gradient);
}
.LightRayFour {
position: absolute;
width: 330px;
height: 370px;
top: -330px;
left: 50px;
mix-blend-mode: overlay;
opacity: 0.5;
filter: blur(21px);
border-radius: 100%;
background: var(--ray-gradient);
}
.LightRayFive {
position: absolute;
width: 110px;
height: 400px;
transform: rotate(-40deg);
top: -280px;
left: -10px;
mix-blend-mode: overlay;
opacity: 0.8;
filter: blur(60px);
border-radius: 100%;
background: var(--ray-gradient);
}
.PromptEffectContainer {
--prompt-container-offset: 50px;
--prompt-line-stroke-width: 1px;

View File

@@ -17,14 +17,15 @@ import Cookies from 'js-cookie';
import * as Tooltip from '@radix-ui/react-tooltip';
import styles from './BaseChat.module.scss';
import type { ProviderInfo } from '~/utils/types';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
import GitCloneButton from './GitCloneButton';
import FilePreview from './FilePreview';
import { ModelSelector } from '~/components/chat/ModelSelector';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
import { ScreenshotStateManager } from './ScreenshotStateManager';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -45,6 +46,7 @@ interface BaseChatProps {
setModel?: (model: string) => void;
provider?: ProviderInfo;
setProvider?: (provider: ProviderInfo) => void;
providerList?: ProviderInfo[];
handleStop?: () => void;
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
@@ -70,6 +72,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
setModel,
provider,
setProvider,
providerList,
input = '',
enhancingPrompt,
handleInputChange,
@@ -88,14 +91,30 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
ref,
) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
const savedKeys = Cookies.get('apiKeys');
if (savedKeys) {
try {
return JSON.parse(savedKeys);
} catch (error) {
console.error('Failed to parse API keys from cookies:', error);
return {};
}
}
return {};
});
const [modelList, setModelList] = useState(MODEL_LIST);
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const [transcript, setTranscript] = useState('');
console.log(transcript);
useEffect(() => {
console.log(transcript);
}, [transcript]);
useEffect(() => {
// Load API keys from cookies on component mount
try {
@@ -115,7 +134,26 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
Cookies.remove('apiKeys');
}
initializeModelList().then((modelList) => {
let providerSettings: Record<string, IProviderSetting> | undefined = undefined;
try {
const savedProviderSettings = Cookies.get('providers');
if (savedProviderSettings) {
const parsedProviderSettings = JSON.parse(savedProviderSettings);
if (typeof parsedProviderSettings === 'object' && parsedProviderSettings !== null) {
providerSettings = parsedProviderSettings;
}
}
} catch (error) {
console.error('Error loading Provider Settings from cookies:', error);
// Clear invalid cookie data
Cookies.remove('providers');
}
initializeModelList(providerSettings).then((modelList) => {
setModelList(modelList);
});
@@ -184,23 +222,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}
};
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);
}
};
const handleFileUpload = () => {
const input = document.createElement('input');
input.type = 'file';
@@ -256,24 +277,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const baseChat = (
<div
ref={ref}
className={classNames(
styles.BaseChat,
'relative flex flex-col lg:flex-row h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
)}
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
data-chat-visible={showChat}
>
<div className={classNames(styles.RayContainer)}>
<div className={classNames(styles.LightRayOne)}></div>
<div className={classNames(styles.LightRayTwo)}></div>
<div className={classNames(styles.LightRayThree)}></div>
<div className={classNames(styles.LightRayFour)}></div>
<div className={classNames(styles.LightRayFive)}></div>
</div>
<ClientOnly>{() => <Menu />}</ClientOnly>
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[26vh] max-w-chat mx-auto text-center px-4 lg:px-0">
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
Where ideas begin
</h1>
@@ -318,15 +329,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(-45)"
>
<stop offset="0%" stopColor="#1488fc" stopOpacity="0%"></stop>
<stop offset="40%" stopColor="#1488fc" stopOpacity="80%"></stop>
<stop offset="50%" stopColor="#1488fc" stopOpacity="80%"></stop>
<stop offset="100%" stopColor="#1488fc" stopOpacity="0%"></stop>
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
</linearGradient>
<linearGradient id="shine-gradient">
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
<stop offset="40%" stopColor="#8adaff" stopOpacity="80%"></stop>
<stop offset="50%" stopColor="#8adaff" stopOpacity="80%"></stop>
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
</linearGradient>
</defs>
@@ -334,21 +345,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
</svg>
<div>
<div className="flex justify-between items-center mb-2">
<button
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
className={classNames('flex items-center gap-2 p-2 rounded-lg transition-all', {
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
isModelSettingsCollapsed,
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
!isModelSettingsCollapsed,
})}
>
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
<span>Model Settings</span>
</button>
</div>
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
<ModelSelector
key={provider?.name + ':' + modelList.length}
@@ -357,14 +353,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
modelList={modelList}
provider={provider}
setProvider={setProvider}
providerList={PROVIDER_LIST}
providerList={providerList || PROVIDER_LIST}
apiKeys={apiKeys}
/>
{provider && (
{(providerList || []).length > 0 && provider && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => updateApiKey(provider.name, key)}
setApiKey={(key) => {
const newApiKeys = { ...apiKeys, [provider.name]: key };
setApiKeys(newApiKeys);
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
}}
/>
)}
</div>
@@ -395,7 +395,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<textarea
ref={textareaRef}
className={classNames(
'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
'w-full pl-4 pt-4 pr-16 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
'transition-all duration-200',
'hover:border-bolt-elements-focus',
)}
@@ -462,6 +462,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<SendButton
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
isStreaming={isStreaming}
disabled={!providerList || providerList.length === 0}
onClick={(event) => {
if (isStreaming) {
handleStop?.();
@@ -512,6 +513,20 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
disabled={isStreaming}
/>
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
<IconButton
title="Model Settings"
className={classNames('transition-all flex items-center gap-1', {
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
isModelSettingsCollapsed,
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
!isModelSettingsCollapsed,
})}
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
disabled={!providerList || providerList.length === 0}
>
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
{isModelSettingsCollapsed ? <span className="text-xs">{model}</span> : <span />}
</IconButton>
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
@@ -524,7 +539,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
</div>
</div>
{!chatStarted && ImportButtons(importChat)}
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
)}
{!chatStarted &&
ExamplePrompts((event, messageInput) => {
if (isStreaming) {

View File

@@ -17,8 +17,9 @@ import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie';
import type { ProviderInfo } from '~/utils/types';
import { debounce } from '~/utils/debounce';
import { useSettings } from '~/lib/hooks/useSettings';
import type { ProviderInfo } from '~/types/model';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -91,6 +92,7 @@ export const ChatImpl = memo(
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const { activeProviders } = useSettings();
const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
@@ -316,6 +318,7 @@ export const ChatImpl = memo(
setModel={handleModelChange}
provider={provider}
setProvider={handleProviderChange}
providerList={activeProviders}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={(e) => {

View File

@@ -0,0 +1,110 @@
import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
'.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/*lock.json',
'**/*lock.yaml',
];
const ig = ignore().add(IGNORE_PATTERNS);
interface GitCloneButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>;
}
export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
const { ready, gitClone } = useGit();
const onClick = async (_e: any) => {
if (!ready) {
return;
}
const repoUrl = prompt('Enter the Git url');
if (repoUrl) {
const { workdir, data } = await gitClone(repoUrl);
if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8');
// Convert files to common format for command detection
const fileContents = filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
return {
path: filePath,
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
};
})
.filter((f) => f.content);
// Detect and create commands message
const commands = await detectProjectCommands(fileContents);
const commandsMessage = createCommandsMessage(commands);
// Create files message
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
const messages = [filesMessage];
if (commandsMessage) {
messages.push(commandsMessage);
}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
}
};
return (
<button
onClick={onClick}
title="Clone a Git Repo"
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"
>
<span className="i-ph:git-branch" />
Clone a Git Repo
</button>
);
}

View File

@@ -1,102 +1,98 @@
import React from 'react';
import React, { useState } from 'react';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import ignore from 'ignore';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
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 [isLoading, setIsLoading] = useState(false);
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();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const allFiles = Array.from(e.target.files || []);
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);
if (allFiles.length > MAX_FILES) {
const error = new Error(`Too many files: ${allFiles.length}`);
logStore.logError('File import failed - too many files', error, {
fileCount: allFiles.length,
maxFiles: MAX_FILES,
});
toast.error(
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
);
return;
}
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
setIsLoading(true);
const loadingToast = toast.loading(`Importing ${folderName}...`);
try {
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
if (filteredFiles.length === 0) {
const error = new Error('No valid files found');
logStore.logError('File import failed - no valid files', error, { folderName });
toast.error('No files found in the selected folder');
return;
}
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) {
const error = new Error('No text files found');
logStore.logError('File import failed - no text files', error, { folderName });
toast.error('No text files found in the selected folder');
return;
}
if (binaryFilePaths.length > 0) {
logStore.logWarning(`Skipping binary files during import`, {
folderName,
binaryCount: binaryFilePaths.length,
});
}),
);
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
}
const binaryFilesMessage =
binaryFiles.length > 0
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
const message: Message = {
role: 'assistant',
content: `I'll help you set up these files.${binaryFilesMessage}
if (importChat) {
await importChat(folderName, [...messages]);
}
<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]);
logStore.logSystem('Folder imported successfully', {
folderName,
textFileCount: textFiles.length,
binaryFileCount: binaryFilePaths.length,
});
toast.success('Folder imported successfully');
} catch (error) {
logStore.logError('Failed to import folder', error, { folderName });
console.error('Failed to import folder:', error);
toast.error('Failed to import folder');
} finally {
setIsLoading(false);
toast.dismiss(loadingToast);
e.target.value = ''; // Reset file input
}
};
@@ -108,46 +104,8 @@ ${fileArtifacts.join('\n\n')}
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
onChange={handleFileChange}
{...({} as any)}
/>
<button
onClick={() => {
@@ -155,9 +113,10 @@ ${fileArtifacts.join('\n\n')}
input?.click();
}}
className={className}
disabled={isLoading}
>
<div className="i-ph:upload-simple" />
Import Folder
{isLoading ? 'Importing...' : 'Import Folder'}
</button>
</>
);

View File

@@ -1,5 +1,6 @@
import type { ProviderInfo } from '~/types/model';
import type { ModelInfo } from '~/utils/types';
import { useEffect } from 'react';
interface ModelSelectorProps {
model?: string;
@@ -19,6 +20,39 @@ export const ModelSelector = ({
modelList,
providerList,
}: ModelSelectorProps) => {
// Load enabled providers from cookies
// Update enabled providers when cookies change
useEffect(() => {
// If current provider is disabled, switch to first enabled provider
if (providerList.length == 0) {
return;
}
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
const firstEnabledProvider = providerList[0];
setProvider?.(firstEnabledProvider);
// Also update the model to the first available one for the new provider
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
if (firstModel) {
setModel?.(firstModel.name);
}
}
}, [providerList, provider, setProvider, modelList, setModel]);
if (providerList.length === 0) {
return (
<div className="mb-2 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary">
<p className="text-center">
No providers are currently enabled. Please enable at least one provider in the settings to start using the
chat.
</p>
</div>
);
}
return (
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
<select
@@ -52,8 +86,8 @@ export const ModelSelector = ({
>
{[...modelList]
.filter((e) => e.provider == provider?.name && e.name)
.map((modelOption) => (
<option key={modelOption.name} value={modelOption.name}>
.map((modelOption, index) => (
<option key={index} value={modelOption.name}>
{modelOption.label}
</option>
))}

View File

@@ -3,25 +3,30 @@ import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps {
show: boolean;
isStreaming?: boolean;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onImagesSelected?: (images: File[]) => void;
}
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonProps) => {
return (
<AnimatePresence>
{show ? (
<motion.button
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme"
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme disabled:opacity-50 disabled:cursor-not-allowed"
transition={{ ease: customEasingFn, duration: 0.17 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
disabled={disabled}
onClick={(event) => {
event.preventDefault();
onClick?.(event);
if (!disabled) {
onClick?.(event);
}
}}
>
<div className="text-lg">

View File

@@ -1,11 +1,10 @@
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">
<div className="flex flex-col items-center justify-center w-auto">
<input
type="file"
id="chat-import"

View File

@@ -1,6 +1,13 @@
import { LanguageDescription } from '@codemirror/language';
export const supportedLanguages = [
LanguageDescription.of({
name: 'VUE',
extensions: ['vue'],
async load() {
return import('@codemirror/lang-vue').then((module) => module.vue());
},
}),
LanguageDescription.of({
name: 'TS',
extensions: ['ts'],

View File

@@ -0,0 +1,117 @@
import { useSearchParams } from '@remix-run/react';
import { generateId, type Message } from 'ai';
import ignore from 'ignore';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { useGit } from '~/lib/hooks/useGit';
import { useChatHistory } from '~/lib/persistence';
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
'.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/*lock.json',
'**/*lock.yaml',
];
export function GitUrlImport() {
const [searchParams] = useSearchParams();
const { ready: historyReady, importChat } = useChatHistory();
const { ready: gitReady, gitClone } = useGit();
const [imported, setImported] = useState(false);
const importRepo = async (repoUrl?: string) => {
if (!gitReady && !historyReady) {
return;
}
if (repoUrl) {
const ig = ignore().add(IGNORE_PATTERNS);
const { workdir, data } = await gitClone(repoUrl);
if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
const textDecoder = new TextDecoder('utf-8');
// Convert files to common format for command detection
const fileContents = filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
return {
path: filePath,
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
};
})
.filter((f) => f.content);
// Detect and create commands message
const commands = await detectProjectCommands(fileContents);
const commandsMessage = createCommandsMessage(commands);
// Create files message
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
const messages = [filesMessage];
if (commandsMessage) {
messages.push(commandsMessage);
}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
}
};
useEffect(() => {
if (!historyReady || !gitReady || imported) {
return;
}
const url = searchParams.get('url');
if (!url) {
window.location.href = '/';
return;
}
importRepo(url);
setImported(true);
}, [searchParams, historyReady, gitReady, imported]);
return <ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>;
}

View File

@@ -10,18 +10,17 @@ export function Header() {
return (
<header
className={classNames(
'flex items-center bg-bolt-elements-background-depth-1 p-5 border-b h-[var(--header-height)]',
{
'border-transparent': !chat.started,
'border-bolt-elements-borderColor': chat.started,
},
)}
className={classNames('flex items-center p-5 border-b h-[var(--header-height)]', {
'border-transparent': !chat.started,
'border-bolt-elements-borderColor': chat.started,
})}
>
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
<div className="i-ph:sidebar-simple-duotone text-xl" />
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
{/* <span className="i-bolt:logo-text?mask w-[46px] inline-block" /> */}
<img src="/logo-light-styled.png" alt="logo" className="w-[90px] inline-block dark:hidden" />
<img src="/logo-dark-styled.png" alt="logo" className="w-[90px] inline-block hidden dark:block" />
</a>
</div>
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.

View File

@@ -0,0 +1,63 @@
.settings-tabs {
button {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
text-align: left;
font-size: 0.875rem;
transition: all 0.2s;
margin-bottom: 0.5rem;
&.active {
background: var(--bolt-elements-button-primary-background);
color: var(--bolt-elements-textPrimary);
}
&:not(.active) {
background: var(--bolt-elements-bg-depth-3);
color: var(--bolt-elements-textPrimary);
&:hover {
background: var(--bolt-elements-button-primary-backgroundHover);
}
}
}
}
.settings-button {
background-color: var(--bolt-elements-button-primary-background);
color: var(--bolt-elements-textPrimary);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--bolt-elements-button-primary-backgroundHover);
}
}
.settings-danger-area {
background-color: transparent;
color: var(--bolt-elements-textPrimary);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
border-style: solid;
border-color: var(--bolt-elements-button-danger-backgroundHover);
border-width: thin;
button {
background-color: var(--bolt-elements-button-danger-background);
color: var(--bolt-elements-button-danger-text);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--bolt-elements-button-danger-backgroundHover);
}
}
}

View File

@@ -0,0 +1,128 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { useState, type ReactElement } from 'react';
import { classNames } from '~/utils/classNames';
import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog';
import { IconButton } from '~/components/ui/IconButton';
import styles from './Settings.module.scss';
import ChatHistoryTab from './chat-history/ChatHistoryTab';
import ProvidersTab from './providers/ProvidersTab';
import { useSettings } from '~/lib/hooks/useSettings';
import FeaturesTab from './features/FeaturesTab';
import DebugTab from './debug/DebugTab';
import EventLogsTab from './event-logs/EventLogsTab';
import ConnectionsTab from './connections/ConnectionsTab';
interface SettingsProps {
open: boolean;
onClose: () => void;
}
type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
const { debug, eventLogs } = useSettings();
const [activeTab, setActiveTab] = useState<TabType>('chat-history');
const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [
{ id: 'chat-history', label: 'Chat History', icon: 'i-ph:book', component: <ChatHistoryTab /> },
{ id: 'providers', label: 'Providers', icon: 'i-ph:key', component: <ProvidersTab /> },
{ id: 'features', label: 'Features', icon: 'i-ph:star', component: <FeaturesTab /> },
{ id: 'connection', label: 'Connection', icon: 'i-ph:link', component: <ConnectionsTab /> },
...(debug
? [
{
id: 'debug' as TabType,
label: 'Debug Tab',
icon: 'i-ph:bug',
component: <DebugTab />,
},
]
: []),
...(eventLogs
? [
{
id: 'event-logs' as TabType,
label: 'Event Logs',
icon: 'i-ph:list-bullets',
component: <EventLogsTab />,
},
]
: []),
];
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild onClick={onClose}>
<motion.div
className="bg-black/50 fixed inset-0 z-max backdrop-blur-sm"
initial="closed"
animate="open"
exit="closed"
variants={dialogBackdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className="fixed top-[50%] left-[50%] z-max h-[85vh] w-[90vw] max-w-[900px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg shadow-lg focus:outline-none overflow-hidden"
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<div className="flex h-full">
<div
className={classNames(
'w-48 border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 p-4 flex flex-col justify-between',
styles['settings-tabs'],
)}
>
<DialogTitle className="flex-shrink-0 text-lg font-semibold text-bolt-elements-textPrimary mb-2">
Settings
</DialogTitle>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={classNames(activeTab === tab.id ? styles.active : '')}
>
<div className={tab.icon} />
{tab.label}
</button>
))}
<div className="mt-auto flex flex-col gap-2">
<a
href="https://github.com/stackblitz-labs/bolt.diy"
target="_blank"
rel="noopener noreferrer"
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
>
<div className="i-ph:github-logo" />
GitHub
</a>
<a
href="https://stackblitz-labs.github.io/bolt.diy/"
target="_blank"
rel="noopener noreferrer"
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
>
<div className="i-ph:book" />
Docs
</a>
</div>
</div>
<div className="flex-1 flex flex-col p-8 pt-10 bg-bolt-elements-background-depth-2">
<div className="flex-1 overflow-y-auto">{tabs.find((tab) => tab.id === activeTab)?.component}</div>
</div>
</div>
<RadixDialog.Close asChild onClick={onClose}>
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
</RadixDialog.Close>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};

View File

@@ -0,0 +1,113 @@
import { useNavigate } from '@remix-run/react';
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { db, deleteById, getAll } from '~/lib/persistence';
import { classNames } from '~/utils/classNames';
import styles from '~/components/settings/Settings.module.scss';
import { logStore } from '~/lib/stores/logs'; // Import logStore for event logging
export default function ChatHistoryTab() {
const navigate = useNavigate();
const [isDeleting, setIsDeleting] = useState(false);
const downloadAsJson = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleDeleteAllChats = async () => {
if (!db) {
const error = new Error('Database is not available');
logStore.logError('Failed to delete chats - DB unavailable', error);
toast.error('Database is not available');
return;
}
try {
setIsDeleting(true);
const allChats = await getAll(db);
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
logStore.logSystem('All chats deleted successfully', { count: allChats.length });
toast.success('All chats deleted successfully');
navigate('/', { replace: true });
} catch (error) {
logStore.logError('Failed to delete chats', error);
toast.error('Failed to delete chats');
console.error(error);
} finally {
setIsDeleting(false);
}
};
const handleExportAllChats = async () => {
if (!db) {
const error = new Error('Database is not available');
logStore.logError('Failed to export chats - DB unavailable', error);
toast.error('Database is not available');
return;
}
try {
const allChats = await getAll(db);
const exportData = {
chats: allChats,
exportDate: new Date().toISOString(),
};
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
logStore.logSystem('Chats exported successfully', { count: allChats.length });
toast.success('Chats exported successfully');
} catch (error) {
logStore.logError('Failed to export chats', error);
toast.error('Failed to export chats');
console.error(error);
}
};
return (
<>
<div className="p-4">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Chat History</h3>
<button
onClick={handleExportAllChats}
className={classNames(
'bg-bolt-elements-button-primary-background',
'rounded-lg px-4 py-2 mb-4 transition-colors duration-200',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'text-bolt-elements-button-primary-text',
)}
>
Export All Chats
</button>
<div
className={classNames('text-bolt-elements-textPrimary rounded-lg py-4 mb-4', styles['settings-danger-area'])}
>
<h4 className="font-semibold">Danger Area</h4>
<p className="mb-2">This action cannot be undone!</p>
<button
onClick={handleDeleteAllChats}
disabled={isDeleting}
className={classNames(
'bg-bolt-elements-button-danger-background',
'rounded-lg px-4 py-2 transition-colors duration-200',
isDeleting ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bolt-elements-button-danger-backgroundHover',
'text-bolt-elements-button-danger-text',
)}
>
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,53 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import Cookies from 'js-cookie';
import { logStore } from '~/lib/stores/logs';
export default function ConnectionsTab() {
const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || '');
const handleSaveConnection = () => {
Cookies.set('githubUsername', githubUsername);
Cookies.set('githubToken', githubToken);
logStore.logSystem('GitHub connection settings updated', {
username: githubUsername,
hasToken: !!githubToken,
});
toast.success('GitHub credentials saved successfully!');
};
return (
<div className="p-4 mb-4 border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-3">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">GitHub Connection</h3>
<div className="flex mb-4">
<div className="flex-1 mr-2">
<label className="block text-sm text-bolt-elements-textSecondary mb-1">GitHub Username:</label>
<input
type="text"
value={githubUsername}
onChange={(e) => setGithubUsername(e.target.value)}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
<div className="flex-1">
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Personal Access Token:</label>
<input
type="password"
value={githubToken}
onChange={(e) => setGithubToken(e.target.value)}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
</div>
<div className="flex mb-4">
<button
onClick={handleSaveConnection}
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 mr-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
>
Save Connection
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,494 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import commit from '~/commit.json';
interface ProviderStatus {
name: string;
enabled: boolean;
isLocal: boolean;
isRunning: boolean | null;
error?: string;
lastChecked: Date;
responseTime?: number;
url: string | null;
}
interface SystemInfo {
os: string;
browser: string;
screen: string;
language: string;
timezone: string;
memory: string;
cores: number;
}
interface IProviderConfig {
name: string;
settings: {
enabled: boolean;
};
}
const LOCAL_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
const versionHash = commit.commit;
const GITHUB_URLS = {
original: 'https://api.github.com/repos/stackblitz-labs/bolt.diy/commits/main',
fork: 'https://api.github.com/repos/Stijnus/bolt.new-any-llm/commits/main',
};
function getSystemInfo(): SystemInfo {
const formatBytes = (bytes: number): string => {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return {
os: navigator.platform,
browser: navigator.userAgent.split(' ').slice(-1)[0],
screen: `${window.screen.width}x${window.screen.height}`,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
memory: formatBytes(performance?.memory?.jsHeapSizeLimit || 0),
cores: navigator.hardwareConcurrency || 0,
};
}
const checkProviderStatus = async (url: string | null, providerName: string): Promise<ProviderStatus> => {
if (!url) {
console.log(`[Debug] No URL provided for ${providerName}`);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: false,
error: 'No URL configured',
lastChecked: new Date(),
url: null,
};
}
console.log(`[Debug] Checking status for ${providerName} at ${url}`);
const startTime = performance.now();
try {
if (providerName.toLowerCase() === 'ollama') {
// Special check for Ollama root endpoint
try {
console.log(`[Debug] Checking Ollama root endpoint: ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'text/plain,application/json',
},
});
clearTimeout(timeoutId);
const text = await response.text();
console.log(`[Debug] Ollama root response:`, text);
if (text.includes('Ollama is running')) {
console.log(`[Debug] Ollama running confirmed via root endpoint`);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: true,
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
}
} catch (error) {
console.log(`[Debug] Ollama root check failed:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('aborted')) {
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: false,
error: 'Connection timeout',
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
}
}
}
// Try different endpoints based on provider
const checkUrls = [`${url}/api/health`, `${url}/v1/models`];
console.log(`[Debug] Checking additional endpoints:`, checkUrls);
const results = await Promise.all(
checkUrls.map(async (checkUrl) => {
try {
console.log(`[Debug] Trying endpoint: ${checkUrl}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(checkUrl, {
signal: controller.signal,
headers: {
Accept: 'application/json',
},
});
clearTimeout(timeoutId);
const ok = response.ok;
console.log(`[Debug] Endpoint ${checkUrl} response:`, ok);
if (ok) {
try {
const data = await response.json();
console.log(`[Debug] Endpoint ${checkUrl} data:`, data);
} catch {
console.log(`[Debug] Could not parse JSON from ${checkUrl}`);
}
}
return ok;
} catch (error) {
console.log(`[Debug] Endpoint ${checkUrl} failed:`, error);
return false;
}
}),
);
const isRunning = results.some((result) => result);
console.log(`[Debug] Final status for ${providerName}:`, isRunning);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning,
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
} catch (error) {
console.log(`[Debug] Provider check failed for ${providerName}:`, error);
return {
name: providerName,
enabled: false,
isLocal: true,
isRunning: false,
error: error instanceof Error ? error.message : 'Unknown error',
lastChecked: new Date(),
responseTime: performance.now() - startTime,
url,
};
}
};
export default function DebugTab() {
const { providers } = useSettings();
const [activeProviders, setActiveProviders] = useState<ProviderStatus[]>([]);
const [updateMessage, setUpdateMessage] = useState<string>('');
const [systemInfo] = useState<SystemInfo>(getSystemInfo());
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const updateProviderStatuses = async () => {
if (!providers) {
return;
}
try {
const entries = Object.entries(providers) as [string, IProviderConfig][];
const statuses = entries
.filter(([, provider]) => LOCAL_PROVIDERS.includes(provider.name))
.map(async ([, provider]) => {
const envVarName =
provider.name.toLowerCase() === 'ollama'
? 'OLLAMA_API_BASE_URL'
: provider.name.toLowerCase() === 'lmstudio'
? 'LMSTUDIO_API_BASE_URL'
: `REACT_APP_${provider.name.toUpperCase()}_URL`;
// Access environment variables through import.meta.env
const url = import.meta.env[envVarName] || null;
console.log(`[Debug] Using URL for ${provider.name}:`, url, `(from ${envVarName})`);
const status = await checkProviderStatus(url, provider.name);
return {
...status,
enabled: provider.settings.enabled ?? false,
};
});
Promise.all(statuses).then(setActiveProviders);
} catch (error) {
console.error('[Debug] Failed to update provider statuses:', error);
}
};
useEffect(() => {
updateProviderStatuses();
const interval = setInterval(updateProviderStatuses, 30000);
return () => clearInterval(interval);
}, [providers]);
const handleCheckForUpdate = useCallback(async () => {
if (isCheckingUpdate) {
return;
}
try {
setIsCheckingUpdate(true);
setUpdateMessage('Checking for updates...');
const [originalResponse, forkResponse] = await Promise.all([
fetch(GITHUB_URLS.original),
fetch(GITHUB_URLS.fork),
]);
if (!originalResponse.ok || !forkResponse.ok) {
throw new Error('Failed to fetch repository information');
}
const [originalData, forkData] = await Promise.all([
originalResponse.json() as Promise<{ sha: string }>,
forkResponse.json() as Promise<{ sha: string }>,
]);
const originalCommitHash = originalData.sha;
const forkCommitHash = forkData.sha;
const isForked = versionHash === forkCommitHash && forkCommitHash !== originalCommitHash;
if (originalCommitHash !== versionHash) {
setUpdateMessage(
`Update available from original repository!\n` +
`Current: ${versionHash.slice(0, 7)}${isForked ? ' (forked)' : ''}\n` +
`Latest: ${originalCommitHash.slice(0, 7)}`,
);
} else {
setUpdateMessage('You are on the latest version from the original repository');
}
} catch (error) {
setUpdateMessage('Failed to check for updates');
console.error('[Debug] Failed to check for updates:', error);
} finally {
setIsCheckingUpdate(false);
}
}, [isCheckingUpdate]);
const handleCopyToClipboard = useCallback(() => {
const debugInfo = {
System: systemInfo,
Providers: activeProviders.map((provider) => ({
name: provider.name,
enabled: provider.enabled,
isLocal: provider.isLocal,
running: provider.isRunning,
error: provider.error,
lastChecked: provider.lastChecked,
responseTime: provider.responseTime,
url: provider.url,
})),
Version: versionHash,
Timestamp: new Date().toISOString(),
};
navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
alert('Debug information copied to clipboard!');
});
}, [activeProviders, systemInfo]);
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Debug Information</h3>
<div className="flex gap-2">
<button
onClick={handleCopyToClipboard}
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
>
Copy Debug Info
</button>
<button
onClick={handleCheckForUpdate}
disabled={isCheckingUpdate}
className={`bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200
${!isCheckingUpdate ? 'hover:bg-bolt-elements-button-primary-backgroundHover' : 'opacity-75 cursor-not-allowed'}
text-bolt-elements-button-primary-text`}
>
{isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
</button>
</div>
</div>
{updateMessage && (
<div
className={`bg-bolt-elements-surface rounded-lg p-3 ${
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-400' : ''
}`}
>
<p className="text-bolt-elements-textSecondary whitespace-pre-line">{updateMessage}</p>
{updateMessage.includes('Update available') && (
<div className="mt-3 text-sm">
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
<ol className="list-decimal ml-4 mt-1 text-bolt-elements-textSecondary">
<li>
Pull the latest changes:{' '}
<code className="bg-bolt-elements-surface-hover px-1 rounded">git pull upstream main</code>
</li>
<li>
Install any new dependencies:{' '}
<code className="bg-bolt-elements-surface-hover px-1 rounded">pnpm install</code>
</li>
<li>Restart the application</li>
</ol>
</div>
)}
</div>
)}
<section className="space-y-4">
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">System Information</h4>
<div className="bg-bolt-elements-surface rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-xs text-bolt-elements-textSecondary">Operating System</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.os}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Browser</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.browser}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Screen Resolution</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.screen}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Language</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.language}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">Timezone</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.timezone}</p>
</div>
<div>
<p className="text-xs text-bolt-elements-textSecondary">CPU Cores</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.cores}</p>
</div>
</div>
<div className="mt-3 pt-3 border-t border-bolt-elements-surface-hover">
<p className="text-xs text-bolt-elements-textSecondary">Version</p>
<p className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
{versionHash.slice(0, 7)}
<span className="ml-2 text-xs text-bolt-elements-textSecondary">
({new Date().toLocaleDateString()})
</span>
</p>
</div>
</div>
</div>
<div>
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">Local LLM Status</h4>
<div className="bg-bolt-elements-surface rounded-lg">
<div className="grid grid-cols-1 divide-y">
{activeProviders.map((provider) => (
<div key={provider.name} className="p-3 flex flex-col space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<div
className={`w-2 h-2 rounded-full ${
!provider.enabled ? 'bg-gray-300' : provider.isRunning ? 'bg-green-400' : 'bg-red-400'
}`}
/>
</div>
<div>
<p className="text-sm font-medium text-bolt-elements-textPrimary">{provider.name}</p>
{provider.url && (
<p className="text-xs text-bolt-elements-textSecondary truncate max-w-[300px]">
{provider.url}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 text-xs rounded-full ${
provider.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</span>
{provider.enabled && (
<span
className={`px-2 py-0.5 text-xs rounded-full ${
provider.isRunning ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{provider.isRunning ? 'Running' : 'Not Running'}
</span>
)}
</div>
</div>
<div className="pl-5 flex flex-col space-y-1 text-xs">
{/* Status Details */}
<div className="flex flex-wrap gap-2">
<span className="text-bolt-elements-textSecondary">
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
</span>
{provider.responseTime && (
<span className="text-bolt-elements-textSecondary">
Response time: {Math.round(provider.responseTime)}ms
</span>
)}
</div>
{/* Error Message */}
{provider.error && (
<div className="mt-1 text-red-600 bg-red-50 rounded-md p-2">
<span className="font-medium">Error:</span> {provider.error}
</div>
)}
{/* Connection Info */}
{provider.url && (
<div className="text-bolt-elements-textSecondary">
<span className="font-medium">Endpoints checked:</span>
<ul className="list-disc list-inside pl-2 mt-1">
<li>{provider.url} (root)</li>
<li>{provider.url}/api/health</li>
<li>{provider.url}/v1/models</li>
</ul>
</div>
)}
</div>
</div>
))}
{activeProviders.length === 0 && (
<div className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
)}
</div>
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useSettings } from '~/lib/hooks/useSettings';
import { toast } from 'react-toastify';
import { Switch } from '~/components/ui/Switch';
import { logStore, type LogEntry } from '~/lib/stores/logs';
import { useStore } from '@nanostores/react';
import { classNames } from '~/utils/classNames';
export default function EventLogsTab() {
const {} = useSettings();
const showLogs = useStore(logStore.showLogs);
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
const [autoScroll, setAutoScroll] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [, forceUpdate] = useState({});
const filteredLogs = useMemo(() => {
const logs = logStore.getLogs();
return logs.filter((log) => {
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
const matchesSearch =
!searchQuery ||
log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
JSON.stringify(log.details)?.toLowerCase()?.includes(searchQuery?.toLowerCase());
return matchesLevel && matchesSearch;
});
}, [logLevel, searchQuery]);
// Effect to initialize showLogs
useEffect(() => {
logStore.showLogs.set(true);
}, []);
useEffect(() => {
// System info logs
logStore.logSystem('Application initialized', {
version: process.env.NEXT_PUBLIC_APP_VERSION,
environment: process.env.NODE_ENV,
});
// Debug logs for system state
logStore.logDebug('System configuration loaded', {
runtime: 'Next.js',
features: ['AI Chat', 'Event Logging'],
});
// Warning logs for potential issues
logStore.logWarning('Resource usage threshold approaching', {
memoryUsage: '75%',
cpuLoad: '60%',
});
// Error logs with detailed context
logStore.logError('API connection failed', new Error('Connection timeout'), {
endpoint: '/api/chat',
retryCount: 3,
lastAttempt: new Date().toISOString(),
});
}, []);
useEffect(() => {
const container = document.querySelector('.logs-container');
if (container && autoScroll) {
container.scrollTop = container.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const handleClearLogs = useCallback(() => {
if (confirm('Are you sure you want to clear all logs?')) {
logStore.clearLogs();
toast.success('Logs cleared successfully');
forceUpdate({}); // Force a re-render after clearing logs
}
}, []);
const handleExportLogs = useCallback(() => {
try {
const logText = logStore
.getLogs()
.map(
(log) =>
`[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}${
log.details ? '\nDetails: ' + JSON.stringify(log.details, null, 2) : ''
}`,
)
.join('\n\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-logs-${new Date().toISOString()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Logs exported successfully');
} catch (error) {
toast.error('Failed to export logs');
console.error('Export error:', error);
}
}, []);
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'info':
return 'text-blue-500';
case 'warning':
return 'text-yellow-500';
case 'error':
return 'text-red-500';
case 'debug':
return 'text-gray-500';
default:
return 'text-bolt-elements-textPrimary';
}
};
return (
<div className="p-4 h-full flex flex-col">
<div className="flex flex-col space-y-4 mb-4">
{/* Title and Toggles Row */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
</div>
</div>
</div>
{/* Controls Row */}
<div className="flex flex-wrap items-center gap-2">
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
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 lg:max-w-[20%] text-sm min-w-[100px]"
>
<option value="all">All</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</select>
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
{showLogs && (
<div className="flex items-center gap-2 flex-nowrap">
<button
onClick={handleExportLogs}
className={classNames(
'bg-bolt-elements-button-primary-background',
'rounded-lg px-4 py-2 transition-colors duration-200',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'text-bolt-elements-button-primary-text',
)}
>
Export Logs
</button>
<button
onClick={handleClearLogs}
className={classNames(
'bg-bolt-elements-button-danger-background',
'rounded-lg px-4 py-2 transition-colors duration-200',
'hover:bg-bolt-elements-button-danger-backgroundHover',
'text-bolt-elements-button-danger-text',
)}
>
Clear Logs
</button>
</div>
)}
</div>
</div>
<div className="bg-bolt-elements-bg-depth-1 rounded-lg p-4 h-[calc(100vh - 250px)] min-h-[400px] overflow-y-auto logs-container overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="text-center text-bolt-elements-textSecondary py-8">No logs found</div>
) : (
filteredLogs.map((log, index) => (
<div
key={index}
className="text-sm mb-3 font-mono border-b border-bolt-elements-borderColor pb-2 last:border-0"
>
<div className="flex items-start space-x-2 flex-wrap">
<span className={`font-bold ${getLevelColor(log.level)} whitespace-nowrap`}>
[{log.level.toUpperCase()}]
</span>
<span className="text-bolt-elements-textSecondary whitespace-nowrap">
{new Date(log.timestamp).toLocaleString()}
</span>
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
</div>
{log.details && (
<pre className="mt-2 text-xs text-bolt-elements-textSecondary overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(log.details, null, 2)}
</pre>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings';
export default function FeaturesTab() {
const { debug, enableDebugMode, isLocalModel, enableLocalModels, eventLogs, enableEventLogs } = useSettings();
return (
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
<div className="mb-6">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Optional Features</h3>
<div className="flex items-center justify-between mb-2">
<span className="text-bolt-elements-textPrimary">Debug Info</span>
<Switch className="ml-auto" checked={debug} onCheckedChange={enableDebugMode} />
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-bolt-elements-textPrimary">Event Logs</span>
<Switch className="ml-auto" checked={eventLogs} onCheckedChange={enableEventLogs} />
</div>
</div>
<div className="mb-6 border-t border-bolt-elements-borderColor pt-4">
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Experimental Features</h3>
<p className="text-sm text-bolt-elements-textSecondary mb-4">
Disclaimer: Experimental features may be unstable and are subject to change.
</p>
<div className="flex items-center justify-between mb-2">
<span className="text-bolt-elements-textPrimary">Enable Local Models</span>
<Switch className="ml-auto" checked={isLocalModel} onCheckedChange={enableLocalModels} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import { Switch } from '~/components/ui/Switch';
import { useSettings } from '~/lib/hooks/useSettings';
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
import type { IProviderConfig } from '~/types/model';
import { logStore } from '~/lib/stores/logs';
export default function ProvidersTab() {
const { providers, updateProviderSettings, isLocalModel } = useSettings();
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
// Load base URLs from cookies
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
...value,
name: key,
}));
if (searchTerm && searchTerm.length > 0) {
newFilteredProviders = newFilteredProviders.filter((provider) =>
provider.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}
if (!isLocalModel) {
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
}
newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
setFilteredProviders(newFilteredProviders);
}, [providers, searchTerm, isLocalModel]);
return (
<div className="p-4">
<div className="flex mb-4">
<input
type="text"
placeholder="Search providers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
{filteredProviders.map((provider) => (
<div
key={provider.name}
className="flex flex-col mb-2 provider-item hover:bg-bolt-elements-bg-depth-3 p-4 rounded-lg border border-bolt-elements-borderColor "
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<img src={`/icons/${provider.name}.svg`} alt={`${provider.name} icon`} className="w-6 h-6 dark:invert" />
<span className="text-bolt-elements-textPrimary">{provider.name}</span>
</div>
<Switch
className="ml-auto"
checked={provider.settings.enabled}
onCheckedChange={(enabled) => {
updateProviderSettings(provider.name, { ...provider.settings, enabled });
if (enabled) {
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
} else {
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
}
}}
/>
</div>
{/* Base URL input for configurable providers */}
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.settings.enabled && (
<div className="mt-2">
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Base URL:</label>
<input
type="text"
value={provider.settings.baseUrl || ''}
onChange={(e) => {
const newBaseUrl = e.target.value;
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
logStore.logProvider(`Base URL updated for ${provider.name}`, {
provider: provider.name,
baseUrl: newBaseUrl,
});
}}
placeholder={`Enter ${provider.name} base URL`}
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
/>
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -3,6 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { SettingsWindow } from '~/components/settings/SettingsWindow';
import { SettingsButton } from '~/components/ui/SettingsButton';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
@@ -33,12 +35,32 @@ const menuVariants = {
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
function CurrentDateTime() {
const [dateTime, setDateTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
setDateTime(new Date());
}, 60000); // Update every minute
return () => clearInterval(timer);
}, []);
return (
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor">
<div className="h-4 w-4 i-ph:clock-thin" />
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
);
}
export const Menu = () => {
const { duplicateCurrentChat, exportChat } = useChatHistory();
const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false);
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
items: list,
@@ -123,18 +145,17 @@ export const Menu = () => {
variants={menuVariants}
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
>
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
<div className="h-[60px]" /> {/* Spacer for top margin */}
<CurrentDateTime />
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="p-4 select-none">
<a
href="/"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4"
>
<span className="inline-block i-bolt:chat scale-110" />
Start new chat
</a>
</div>
<div className="pl-4 pr-4 my-2">
<div className="relative w-full">
<input
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
@@ -200,10 +221,12 @@ export const Menu = () => {
</Dialog>
</DialogRoot>
</div>
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
<ThemeSwitch className="ml-auto" />
<div className="flex items-center justify-between border-t border-bolt-elements-borderColor p-4">
<SettingsButton onClick={() => setIsSettingsOpen(true)} />
<ThemeSwitch />
</div>
</div>
<SettingsWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
</motion.div>
);
};

View File

@@ -0,0 +1,18 @@
import styles from './styles.module.scss';
const BackgroundRays = () => {
return (
<div className={`${styles.rayContainer} `}>
<div className={`${styles.lightRay} ${styles.ray1}`}></div>
<div className={`${styles.lightRay} ${styles.ray2}`}></div>
<div className={`${styles.lightRay} ${styles.ray3}`}></div>
<div className={`${styles.lightRay} ${styles.ray4}`}></div>
<div className={`${styles.lightRay} ${styles.ray5}`}></div>
<div className={`${styles.lightRay} ${styles.ray6}`}></div>
<div className={`${styles.lightRay} ${styles.ray7}`}></div>
<div className={`${styles.lightRay} ${styles.ray8}`}></div>
</div>
);
};
export default BackgroundRays;

View File

@@ -0,0 +1,246 @@
.rayContainer {
// Theme-specific colors
--ray-color-primary: color-mix(in srgb, var(--primary-color), transparent 30%);
--ray-color-secondary: color-mix(in srgb, var(--secondary-color), transparent 30%);
--ray-color-accent: color-mix(in srgb, var(--accent-color), transparent 30%);
// Theme-specific gradients
--ray-gradient-primary: radial-gradient(var(--ray-color-primary) 0%, transparent 70%);
--ray-gradient-secondary: radial-gradient(var(--ray-color-secondary) 0%, transparent 70%);
--ray-gradient-accent: radial-gradient(var(--ray-color-accent) 0%, transparent 70%);
position: fixed;
inset: 0;
overflow: hidden;
animation: fadeIn 1.5s ease-out;
pointer-events: none;
z-index: 0;
// background-color: transparent;
:global(html[data-theme='dark']) & {
mix-blend-mode: screen;
}
:global(html[data-theme='light']) & {
mix-blend-mode: multiply;
}
}
.lightRay {
position: absolute;
border-radius: 100%;
:global(html[data-theme='dark']) & {
mix-blend-mode: screen;
}
:global(html[data-theme='light']) & {
mix-blend-mode: multiply;
opacity: 0.4;
}
}
.ray1 {
width: 600px;
height: 800px;
background: var(--ray-gradient-primary);
transform: rotate(65deg);
top: -500px;
left: -100px;
filter: blur(80px);
opacity: 0.6;
animation: float1 15s infinite ease-in-out;
}
.ray2 {
width: 400px;
height: 600px;
background: var(--ray-gradient-secondary);
transform: rotate(-30deg);
top: -300px;
left: 200px;
filter: blur(60px);
opacity: 0.6;
animation: float2 18s infinite ease-in-out;
}
.ray3 {
width: 500px;
height: 400px;
background: var(--ray-gradient-accent);
top: -320px;
left: 500px;
filter: blur(65px);
opacity: 0.5;
animation: float3 20s infinite ease-in-out;
}
.ray4 {
width: 400px;
height: 450px;
background: var(--ray-gradient-secondary);
top: -350px;
left: 800px;
filter: blur(55px);
opacity: 0.55;
animation: float4 17s infinite ease-in-out;
}
.ray5 {
width: 350px;
height: 500px;
background: var(--ray-gradient-primary);
transform: rotate(-45deg);
top: -250px;
left: 1000px;
filter: blur(45px);
opacity: 0.6;
animation: float5 16s infinite ease-in-out;
}
.ray6 {
width: 300px;
height: 700px;
background: var(--ray-gradient-accent);
transform: rotate(75deg);
top: -400px;
left: 600px;
filter: blur(75px);
opacity: 0.45;
animation: float6 19s infinite ease-in-out;
}
.ray7 {
width: 450px;
height: 600px;
background: var(--ray-gradient-primary);
transform: rotate(45deg);
top: -450px;
left: 350px;
filter: blur(65px);
opacity: 0.55;
animation: float7 21s infinite ease-in-out;
}
.ray8 {
width: 380px;
height: 550px;
background: var(--ray-gradient-secondary);
transform: rotate(-60deg);
top: -380px;
left: 750px;
filter: blur(58px);
opacity: 0.6;
animation: float8 14s infinite ease-in-out;
}
@keyframes float1 {
0%,
100% {
transform: rotate(65deg) translate(0, 0);
}
25% {
transform: rotate(70deg) translate(30px, 20px);
}
50% {
transform: rotate(60deg) translate(-20px, 40px);
}
75% {
transform: rotate(68deg) translate(-40px, 10px);
}
}
@keyframes float2 {
0%,
100% {
transform: rotate(-30deg) scale(1);
}
33% {
transform: rotate(-25deg) scale(1.1);
}
66% {
transform: rotate(-35deg) scale(0.95);
}
}
@keyframes float3 {
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
25% {
transform: translate(40px, 20px) rotate(5deg);
}
75% {
transform: translate(-30px, 40px) rotate(-5deg);
}
}
@keyframes float4 {
0%,
100% {
transform: scale(1) rotate(0deg);
}
50% {
transform: scale(1.15) rotate(10deg);
}
}
@keyframes float5 {
0%,
100% {
transform: rotate(-45deg) translate(0, 0);
}
33% {
transform: rotate(-40deg) translate(25px, -20px);
}
66% {
transform: rotate(-50deg) translate(-25px, 20px);
}
}
@keyframes float6 {
0%,
100% {
transform: rotate(75deg) scale(1);
filter: blur(75px);
}
50% {
transform: rotate(85deg) scale(1.1);
filter: blur(65px);
}
}
@keyframes float7 {
0%,
100% {
transform: rotate(45deg) translate(0, 0);
opacity: 0.55;
}
50% {
transform: rotate(40deg) translate(-30px, 30px);
opacity: 0.65;
}
}
@keyframes float8 {
0%,
100% {
transform: rotate(-60deg) scale(1);
}
25% {
transform: rotate(-55deg) scale(1.05);
}
75% {
transform: rotate(-65deg) scale(0.95);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,17 @@
import { memo } from 'react';
import { IconButton } from '~/components/ui/IconButton';
interface SettingsButtonProps {
onClick: () => void;
}
export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
return (
<IconButton
onClick={onClick}
icon="i-ph:gear"
size="xl"
title="Settings"
className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors"
/>
);
});

View File

@@ -0,0 +1,37 @@
import { memo } from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { classNames } from '~/utils/classNames';
interface SwitchProps {
className?: string;
checked?: boolean;
onCheckedChange?: (event: boolean) => void;
}
export const Switch = memo(({ className, onCheckedChange, checked }: SwitchProps) => {
return (
<SwitchPrimitive.Root
className={classNames(
'relative h-6 w-11 cursor-pointer rounded-full bg-bolt-elements-button-primary-background',
'transition-colors duration-200 ease-in-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-bolt-elements-item-contentAccent',
className,
)}
checked={checked}
onCheckedChange={(e) => onCheckedChange?.(e)}
>
<SwitchPrimitive.Thumb
className={classNames(
'block h-5 w-5 rounded-full bg-white',
'shadow-lg shadow-black/20',
'transition-transform duration-200 ease-in-out',
'translate-x-0.5',
'data-[state=checked]:translate-x-[1.375rem]',
'will-change-transform',
)}
/>
</SwitchPrimitive.Root>
);
});

View File

@@ -5,11 +5,16 @@ import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
import { ScreenshotSelector } from './ScreenshotSelector';
type ResizeSide = 'left' | 'right' | null;
export const Preview = memo(() => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const hasSelectedPreview = useRef(false);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
@@ -18,6 +23,23 @@ export const Preview = memo(() => {
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
// Use percentage for width
const [widthPercent, setWidthPercent] = useState<number>(37.5); // 375px assuming 1000px window width initially
const resizingState = useRef({
isResizing: false,
side: null as ResizeSide,
startX: 0,
startWidthPercent: 37.5,
windowWidth: window.innerWidth,
});
// Define the scaling factor
const SCALING_FACTOR = 2; // Adjust this value to increase/decrease sensitivity
useEffect(() => {
if (!activePreview) {
setUrl('');
@@ -27,10 +49,9 @@ export const Preview = memo(() => {
}
const { baseUrl } = activePreview;
setUrl(baseUrl);
setIframeUrl(baseUrl);
}, [activePreview, iframeUrl]);
}, [activePreview]);
const validateUrl = useCallback(
(value: string) => {
@@ -58,14 +79,13 @@ export const Preview = memo(() => {
[],
);
// when previews change, display the lowest port if user hasn't selected a preview
// When previews change, display the lowest port if user hasn't selected a preview
useEffect(() => {
if (previews.length > 1 && !hasSelectedPreview.current) {
const minPortIndex = previews.reduce(findMinPortIndex, 0);
setActivePreviewIndex(minPortIndex);
}
}, [previews]);
}, [previews, findMinPortIndex]);
const reloadPreview = () => {
if (iframeRef.current) {
@@ -73,8 +93,128 @@ export const Preview = memo(() => {
}
};
const toggleFullscreen = async () => {
if (!isFullscreen && containerRef.current) {
await containerRef.current.requestFullscreen();
} else if (document.fullscreenElement) {
await document.exitFullscreen();
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
const toggleDeviceMode = () => {
setIsDeviceModeOn((prev) => !prev);
};
const startResizing = (e: React.MouseEvent, side: ResizeSide) => {
if (!isDeviceModeOn) {
return;
}
// Prevent text selection
document.body.style.userSelect = 'none';
resizingState.current.isResizing = true;
resizingState.current.side = side;
resizingState.current.startX = e.clientX;
resizingState.current.startWidthPercent = widthPercent;
resizingState.current.windowWidth = window.innerWidth;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault(); // Prevent any text selection on mousedown
};
const onMouseMove = (e: MouseEvent) => {
if (!resizingState.current.isResizing) {
return;
}
const dx = e.clientX - resizingState.current.startX;
const windowWidth = resizingState.current.windowWidth;
// Apply scaling factor to increase sensitivity
const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR;
let newWidthPercent = resizingState.current.startWidthPercent;
if (resizingState.current.side === 'right') {
newWidthPercent = resizingState.current.startWidthPercent + dxPercent;
} else if (resizingState.current.side === 'left') {
newWidthPercent = resizingState.current.startWidthPercent - dxPercent;
}
// Clamp the width between 10% and 90%
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
setWidthPercent(newWidthPercent);
};
const onMouseUp = () => {
resizingState.current.isResizing = false;
resizingState.current.side = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Restore text selection
document.body.style.userSelect = '';
};
// Handle window resize to ensure widthPercent remains valid
useEffect(() => {
const handleWindowResize = () => {
/*
* Optional: Adjust widthPercent if necessary
* For now, since widthPercent is relative, no action is needed
*/
};
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, []);
// A small helper component for the handle's "grip" icon
const GripIcon = () => (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
pointerEvents: 'none',
}}
>
<div
style={{
color: 'rgba(0,0,0,0.5)',
fontSize: '10px',
lineHeight: '5px',
userSelect: 'none',
marginLeft: '1px',
}}
>
</div>
</div>
);
return (
<div className="w-full h-full flex flex-col">
<div ref={containerRef} className="w-full h-full flex flex-col relative">
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
@@ -109,6 +249,7 @@ export const Preview = memo(() => {
}}
/>
</div>
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
@@ -119,20 +260,100 @@ export const Preview = memo(() => {
previews={previews}
/>
)}
{/* Device mode toggle button */}
<IconButton
icon="i-ph:devices"
onClick={toggleDeviceMode}
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
/>
{/* Fullscreen toggle button */}
<IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
/>
</div>
<div className="flex-1 border-t border-bolt-elements-borderColor">
{activePreview ? (
<>
<iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} />
<ScreenshotSelector
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
<div
style={{
width: isDeviceModeOn ? `${widthPercent}%` : '100%',
height: '100%', // Always full height
overflow: 'visible',
background: '#fff',
position: 'relative',
display: 'flex',
}}
>
{activePreview ? (
<>
<iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
<ScreenshotSelector
isSelectionMode={isSelectionMode}
setIsSelectionMode={setIsSelectionMode}
containerRef={iframeRef}
/>
</>
) : (
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
)}
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
)}
{isDeviceModeOn && (
<>
{/* Left handle */}
<div
onMouseDown={(e) => startResizing(e, 'left')}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '15px',
marginLeft: '-15px',
height: '100%',
cursor: 'ew-resize',
background: 'rgba(255,255,255,.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
}}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
title="Drag to resize width"
>
<GripIcon />
</div>
{/* Right handle */}
<div
onMouseDown={(e) => startResizing(e, 'right')}
style={{
position: 'absolute',
top: 0,
right: 0,
width: '15px',
marginRight: '-15px',
height: '100%',
cursor: 'ew-resize',
background: 'rgba(255,255,255,.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s',
userSelect: 'none',
}}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
title="Drag to resize width"
>
<GripIcon />
</div>
</>
)}
</div>
</div>
</div>
);

View File

@@ -117,7 +117,7 @@ export const ScreenshotSelector = memo(
// Get window scroll position
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const scrollY = window.scrollY + 40;
// Get the container's position in the page
const containerRect = containerRef.current.getBoundingClientRect();

View File

@@ -17,6 +17,7 @@ import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
import Cookies from 'js-cookie';
interface WorkspaceProps {
chatStarted?: boolean;
@@ -180,21 +181,22 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
return;
}
const githubUsername = prompt('Please enter your GitHub username:');
const githubUsername = Cookies.get('githubUsername');
const githubToken = Cookies.get('githubToken');
if (!githubUsername) {
alert('GitHub username is required. Push to GitHub cancelled.');
return;
if (!githubUsername || !githubToken) {
const usernameInput = prompt('Please enter your GitHub username:');
const tokenInput = prompt('Please enter your GitHub personal access token:');
if (!usernameInput || !tokenInput) {
alert('GitHub username and token are required. Push to GitHub cancelled.');
return;
}
workbenchStore.pushToGitHub(repoName, usernameInput, tokenInput);
} else {
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
}
const githubToken = prompt('Please enter your GitHub personal access token:');
if (!githubToken) {
alert('GitHub token is required. Push to GitHub cancelled.');
return;
}
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
}}
>
<div className="i-ph:github-logo" />

View File

@@ -51,7 +51,7 @@ export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Re
export function getBaseURL(cloudflareEnv: Env, provider: string) {
switch (provider) {
case 'Together':
return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL;
return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL || 'https://api.together.xyz/v1';
case 'OpenAILike':
return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
case 'LMStudio':

View File

@@ -11,6 +11,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { createMistral } from '@ai-sdk/mistral';
import { createCohere } from '@ai-sdk/cohere';
import type { LanguageModelV1 } from 'ai';
import type { IProviderSetting } from '~/types/model';
export const DEFAULT_NUM_CTX = process.env.DEFAULT_NUM_CTX ? parseInt(process.env.DEFAULT_NUM_CTX, 10) : 32768;
@@ -127,14 +128,20 @@ export function getXAIModel(apiKey: OptionalApiKey, model: string) {
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,
apiKeys?: Record<string, string>,
providerSettings?: Record<string, IProviderSetting>,
) {
/*
* let apiKey; // Declare first
* let baseURL;
*/
const apiKey = getAPIKey(env, provider, apiKeys); // Then assign
const baseURL = getBaseURL(env, provider);
const baseURL = providerSettings?.[provider].baseUrl || getBaseURL(env, provider);
switch (provider) {
case 'Anthropic':

View File

@@ -174,14 +174,14 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
- When Using \`npx\`, ALWAYS provide the \`--yes\` flag.
- When running multiple shell commands, use \`&&\` to run them sequentially.
- ULTRA IMPORTANT: Do NOT re-run a dev command with shell action use dev action to run dev commands
- ULTRA IMPORTANT: Do NOT run a dev command with shell action use start 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.
- 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
- start: For starting a development server.
- Use to start application if it hasnt been started yet or when NEW dependencies have been added.
- Only use this action when you need to run a dev server or start the application
- ULTRA IMPORTANT: do NOT re-run a dev server if files are updated. The existing dev server can automatically 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.

View File

@@ -1,11 +1,9 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck TODO: Provider proper types
import { convertToCoreMessages, streamText as _streamText } from 'ai';
import { getModel } from '~/lib/.server/llm/model';
import { MAX_TOKENS } from './constants';
import { getSystemPrompt } from './prompts';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_LIST, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import type { IProviderSetting } from '~/types/model';
interface ToolResult<Name extends string, Args, Result> {
toolCallId: string;
@@ -43,7 +41,7 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid
* Extract provider
* const providerMatch = message.content.match(PROVIDER_REGEX);
*/
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
const cleanedContent = Array.isArray(message.content)
? message.content.map((item) => {
@@ -61,10 +59,17 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid
return { model, provider, content: cleanedContent };
}
export function streamText(messages: Messages, env: Env, options?: StreamingOptions, apiKeys?: Record<string, string>) {
export async function streamText(props: {
messages: Messages;
env: Env;
options?: StreamingOptions;
apiKeys?: Record<string, string>;
providerSettings?: Record<string, IProviderSetting>;
}) {
const { messages, env, options, apiKeys, providerSettings } = props;
let currentModel = DEFAULT_MODEL;
let currentProvider = DEFAULT_PROVIDER;
let currentProvider = DEFAULT_PROVIDER.name;
const MODEL_LIST = await getModelList(apiKeys || {}, providerSettings);
const processedMessages = messages.map((message) => {
if (message.role === 'user') {
const { model, provider, content } = extractPropertiesFromMessage(message);
@@ -86,10 +91,10 @@ export function streamText(messages: Messages, env: Env, options?: StreamingOpti
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
return _streamText({
...options,
model: getModel(currentProvider, currentModel, env, apiKeys),
model: getModel(currentProvider, currentModel, env, apiKeys, providerSettings) as any,
system: getSystemPrompt(),
maxTokens: dynamicMaxTokens,
messages: convertToCoreMessages(processedMessages),
messages: convertToCoreMessages(processedMessages as any),
...options,
});
}

287
app/lib/hooks/useGit.ts Normal file
View File

@@ -0,0 +1,287 @@
import type { WebContainer } from '@webcontainer/api';
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
import { webcontainer as webcontainerPromise } from '~/lib/webcontainer';
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import Cookies from 'js-cookie';
import { toast } from 'react-toastify';
const lookupSavedPassword = (url: string) => {
const domain = url.split('/')[2];
const gitCreds = Cookies.get(`git:${domain}`);
if (!gitCreds) {
return null;
}
try {
const { username, password } = JSON.parse(gitCreds || '{}');
return { username, password };
} catch (error) {
console.log(`Failed to parse Git Cookie ${error}`);
return null;
}
};
const saveGitAuth = (url: string, auth: GitAuth) => {
const domain = url.split('/')[2];
Cookies.set(`git:${domain}`, JSON.stringify(auth));
};
export function useGit() {
const [ready, setReady] = useState(false);
const [webcontainer, setWebcontainer] = useState<WebContainer>();
const [fs, setFs] = useState<PromiseFsClient>();
const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
useEffect(() => {
webcontainerPromise.then((container) => {
fileData.current = {};
setWebcontainer(container);
setFs(getFs(container, fileData));
setReady(true);
});
}, []);
const gitClone = useCallback(
async (url: string) => {
if (!webcontainer || !fs || !ready) {
throw 'Webcontainer not initialized';
}
fileData.current = {};
await git.clone({
fs,
http,
dir: webcontainer.workdir,
url,
depth: 1,
singleBranch: true,
corsProxy: 'https://cors.isomorphic-git.org',
onAuth: (url) => {
// let domain=url.split("/")[2]
let auth = lookupSavedPassword(url);
if (auth) {
return auth;
}
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
auth = {
username: prompt('Enter username'),
password: prompt('Enter password'),
};
return auth;
} else {
return { cancel: true };
}
},
onAuthFailure: (url, _auth) => {
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
},
onAuthSuccess: (url, auth) => {
saveGitAuth(url, auth);
},
});
const data: Record<string, { data: any; encoding?: string }> = {};
for (const [key, value] of Object.entries(fileData.current)) {
data[key] = value;
}
return { workdir: webcontainer.workdir, data };
},
[webcontainer],
);
return { ready, gitClone };
}
const getFs = (
webcontainer: WebContainer,
record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
) => ({
promises: {
readFile: async (path: string, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('readFile', relativePath, encoding);
return await webcontainer.fs.readFile(relativePath, encoding);
},
writeFile: async (path: string, data: any, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('writeFile', { relativePath, data, encoding });
if (record.current) {
record.current[relativePath] = { data, encoding };
}
return await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
},
mkdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('mkdir', relativePath, options);
return await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true });
},
readdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('readdir', relativePath, options);
return await webcontainer.fs.readdir(relativePath, options);
},
rm: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('rm', relativePath, options);
return await webcontainer.fs.rm(relativePath, { ...(options || {}) });
},
rmdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('rmdir', relativePath, options);
return await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
},
// Mock implementations for missing functions
unlink: async (path: string) => {
// unlink is just removing a single file
const relativePath = pathUtils.relative(webcontainer.workdir, path);
return await webcontainer.fs.rm(relativePath, { recursive: false });
},
stat: async (path: string) => {
try {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
const name = pathUtils.basename(relativePath);
const fileInfo = resp.find((x) => x.name == name);
if (!fileInfo) {
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
}
return {
isFile: () => fileInfo.isFile(),
isDirectory: () => fileInfo.isDirectory(),
isSymbolicLink: () => false,
size: 1,
mode: 0o666, // Default permissions
mtimeMs: Date.now(),
uid: 1000,
gid: 1000,
};
} catch (error: any) {
console.log(error?.message);
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.syscall = 'stat';
err.path = path;
throw err;
}
},
lstat: async (path: string) => {
/*
* For basic usage, lstat can return the same as stat
* since we're not handling symbolic links
*/
return await getFs(webcontainer, record).promises.stat(path);
},
readlink: async (path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "not a symbolic link" error
*/
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
},
symlink: async (target: string, path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "operation not supported" error
*/
throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`);
},
chmod: async (_path: string, _mode: number) => {
/*
* WebContainer doesn't support changing permissions,
* but we can pretend it succeeded for compatibility
*/
return await Promise.resolve();
},
},
});
const pathUtils = {
dirname: (path: string) => {
// Handle empty or just filename cases
if (!path || !path.includes('/')) {
return '.';
}
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get directory part
return path.split('/').slice(0, -1).join('/') || '/';
},
basename: (path: string, ext?: string) => {
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get the last part of the path
const base = path.split('/').pop() || '';
// If extension is provided, remove it from the result
if (ext && base.endsWith(ext)) {
return base.slice(0, -ext.length);
}
return base;
},
relative: (from: string, to: string): string => {
// Handle empty inputs
if (!from || !to) {
return '.';
}
// Normalize paths by removing trailing slashes and splitting
const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean);
const fromParts = normalizePathParts(from);
const toParts = normalizePathParts(to);
// Find common parts at the start of both paths
let commonLength = 0;
const minLength = Math.min(fromParts.length, toParts.length);
for (let i = 0; i < minLength; i++) {
if (fromParts[i] !== toParts[i]) {
break;
}
commonLength++;
}
// Calculate the number of "../" needed
const upCount = fromParts.length - commonLength;
// Get the remaining path parts we need to append
const remainingPath = toParts.slice(commonLength);
// Construct the relative path
const relativeParts = [...Array(upCount).fill('..'), ...remainingPath];
// Handle empty result case
return relativeParts.length === 0 ? '.' : relativeParts.join('/');
},
};

View File

@@ -0,0 +1,125 @@
import { useStore } from '@nanostores/react';
import {
isDebugMode,
isEventLogsEnabled,
isLocalModelsEnabled,
LOCAL_PROVIDERS,
providersStore,
} from '~/lib/stores/settings';
import { useCallback, useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
import { logStore } from '~/lib/stores/logs'; // assuming logStore is imported from this location
export function useSettings() {
const providers = useStore(providersStore);
const debug = useStore(isDebugMode);
const eventLogs = useStore(isEventLogsEnabled);
const isLocalModel = useStore(isLocalModelsEnabled);
const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]);
// reading values from cookies on mount
useEffect(() => {
const savedProviders = Cookies.get('providers');
if (savedProviders) {
try {
const parsedProviders: Record<string, IProviderSetting> = JSON.parse(savedProviders);
Object.keys(parsedProviders).forEach((provider) => {
const currentProvider = providers[provider];
providersStore.setKey(provider, {
...currentProvider,
settings: {
...parsedProviders[provider],
enabled: parsedProviders[provider].enabled ?? true,
},
});
});
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
}
}
// load debug mode from cookies
const savedDebugMode = Cookies.get('isDebugEnabled');
if (savedDebugMode) {
isDebugMode.set(savedDebugMode === 'true');
}
// load event logs from cookies
const savedEventLogs = Cookies.get('isEventLogsEnabled');
if (savedEventLogs) {
isEventLogsEnabled.set(savedEventLogs === 'true');
}
// load local models from cookies
const savedLocalModels = Cookies.get('isLocalModelsEnabled');
if (savedLocalModels) {
isLocalModelsEnabled.set(savedLocalModels === 'true');
}
}, []);
// writing values to cookies on change
useEffect(() => {
const providers = providersStore.get();
const providerSetting: Record<string, IProviderSetting> = {};
Object.keys(providers).forEach((provider) => {
providerSetting[provider] = providers[provider].settings;
});
Cookies.set('providers', JSON.stringify(providerSetting));
}, [providers]);
useEffect(() => {
let active = Object.entries(providers)
.filter(([_key, provider]) => provider.settings.enabled)
.map(([_k, p]) => p);
if (!isLocalModel) {
active = active.filter((p) => !LOCAL_PROVIDERS.includes(p.name));
}
setActiveProviders(active);
}, [providers, isLocalModel]);
// helper function to update settings
const updateProviderSettings = useCallback(
(provider: string, config: IProviderSetting) => {
const settings = providers[provider].settings;
providersStore.setKey(provider, { ...providers[provider], settings: { ...settings, ...config } });
},
[providers],
);
const enableDebugMode = useCallback((enabled: boolean) => {
isDebugMode.set(enabled);
logStore.logSystem(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isDebugEnabled', String(enabled));
}, []);
const enableEventLogs = useCallback((enabled: boolean) => {
isEventLogsEnabled.set(enabled);
logStore.logSystem(`Event logs ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isEventLogsEnabled', String(enabled));
}, []);
const enableLocalModels = useCallback((enabled: boolean) => {
isLocalModelsEnabled.set(enabled);
logStore.logSystem(`Local models ${enabled ? 'enabled' : 'disabled'}`);
Cookies.set('isLocalModelsEnabled', String(enabled));
}, []);
return {
providers,
activeProviders,
updateProviderSettings,
debug,
enableDebugMode,
eventLogs,
enableEventLogs,
isLocalModel,
enableLocalModels,
};
}

View File

@@ -4,6 +4,7 @@ import { atom } from 'nanostores';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { logStore } from '~/lib/stores/logs'; // Import logStore
import {
getMessages,
getNextId,
@@ -43,6 +44,8 @@ export function useChatHistory() {
setReady(true);
if (persistenceEnabled) {
const error = new Error('Chat persistence is unavailable');
logStore.logError('Chat persistence initialization failed', error);
toast.error('Chat persistence is unavailable');
}
@@ -69,6 +72,7 @@ export function useChatHistory() {
setReady(true);
})
.catch((error) => {
logStore.logError('Failed to load chat messages', error);
toast.error(error.message);
});
}

View File

@@ -29,6 +29,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -37,6 +38,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -96,6 +98,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -104,6 +107,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -112,6 +116,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -120,6 +125,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -128,6 +134,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": "bundled",
}
`;
@@ -136,6 +143,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": "bundled",
}
`;
@@ -144,6 +152,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -152,6 +161,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -160,6 +170,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -168,6 +179,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -176,6 +188,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -184,6 +197,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -192,6 +206,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -200,6 +215,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -208,6 +224,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;
@@ -216,5 +233,6 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
"type": undefined,
}
`;

View File

@@ -59,7 +59,11 @@ describe('StreamingMessageParser', () => {
},
],
[
['Some text before <boltArti', 'fact', ' title="Some title" id="artifact_1">foo</boltArtifact> Some more text'],
[
'Some text before <boltArti',
'fact',
' title="Some title" id="artifact_1" type="bundled" >foo</boltArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },

View File

@@ -192,6 +192,7 @@ export class StreamingMessageParser {
const artifactTag = input.slice(i, openTagEnd + 1);
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
const type = this.#extractAttribute(artifactTag, 'type') as string;
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
if (!artifactTitle) {
@@ -207,6 +208,7 @@ export class StreamingMessageParser {
const currentArtifact = {
id: artifactId,
title: artifactTitle,
type,
} satisfies BoltArtifactData;
state.currentArtifact = currentArtifact;

149
app/lib/stores/logs.ts Normal file
View File

@@ -0,0 +1,149 @@
import { atom, map } from 'nanostores';
import Cookies from 'js-cookie';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('LogStore');
export interface LogEntry {
id: string;
timestamp: string;
level: 'info' | 'warning' | 'error' | 'debug';
message: string;
details?: Record<string, any>;
category: 'system' | 'provider' | 'user' | 'error';
}
const MAX_LOGS = 1000; // Maximum number of logs to keep in memory
class LogStore {
private _logs = map<Record<string, LogEntry>>({});
showLogs = atom(true);
constructor() {
// Load saved logs from cookies on initialization
this._loadLogs();
}
private _loadLogs() {
const savedLogs = Cookies.get('eventLogs');
if (savedLogs) {
try {
const parsedLogs = JSON.parse(savedLogs);
this._logs.set(parsedLogs);
} catch (error) {
logger.error('Failed to parse logs from cookies:', error);
}
}
}
private _saveLogs() {
const currentLogs = this._logs.get();
Cookies.set('eventLogs', JSON.stringify(currentLogs));
}
private _generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
private _trimLogs() {
const currentLogs = Object.entries(this._logs.get());
if (currentLogs.length > MAX_LOGS) {
const sortedLogs = currentLogs.sort(
([, a], [, b]) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
const newLogs = Object.fromEntries(sortedLogs.slice(0, MAX_LOGS));
this._logs.set(newLogs);
}
}
addLog(
message: string,
level: LogEntry['level'] = 'info',
category: LogEntry['category'] = 'system',
details?: Record<string, any>,
) {
const id = this._generateId();
const entry: LogEntry = {
id,
timestamp: new Date().toISOString(),
level,
message,
details,
category,
};
this._logs.setKey(id, entry);
this._trimLogs();
this._saveLogs();
return id;
}
// System events
logSystem(message: string, details?: Record<string, any>) {
return this.addLog(message, 'info', 'system', details);
}
// Provider events
logProvider(message: string, details?: Record<string, any>) {
return this.addLog(message, 'info', 'provider', details);
}
// User actions
logUserAction(message: string, details?: Record<string, any>) {
return this.addLog(message, 'info', 'user', details);
}
// Error events
logError(message: string, error?: Error | unknown, details?: Record<string, any>) {
const errorDetails = {
...(details || {}),
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
}
: error,
};
return this.addLog(message, 'error', 'error', errorDetails);
}
// Warning events
logWarning(message: string, details?: Record<string, any>) {
return this.addLog(message, 'warning', 'system', details);
}
// Debug events
logDebug(message: string, details?: Record<string, any>) {
return this.addLog(message, 'debug', 'system', details);
}
clearLogs() {
this._logs.set({});
this._saveLogs();
}
getLogs() {
return Object.values(this._logs.get()).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
}
getFilteredLogs(level?: LogEntry['level'], category?: LogEntry['category'], searchQuery?: string) {
return this.getLogs().filter((log) => {
const matchesLevel = !level || level === 'debug' || log.level === level;
const matchesCategory = !category || log.category === category;
const matchesSearch =
!searchQuery ||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
JSON.stringify(log.details).toLowerCase().includes(searchQuery.toLowerCase());
return matchesLevel && matchesCategory && matchesSearch;
});
}
}
export const logStore = new LogStore();

View File

@@ -1,5 +1,7 @@
import { map } from 'nanostores';
import { atom, map } from 'nanostores';
import { workbenchStore } from './workbench';
import { PROVIDER_LIST } from '~/utils/constants';
import type { IProviderConfig } from '~/types/model';
export interface Shortcut {
key: string;
@@ -15,9 +17,10 @@ export interface Shortcuts {
toggleTerminal: Shortcut;
}
export interface Settings {
shortcuts: Shortcuts;
}
export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama'];
export type ProviderSetting = Record<string, IProviderConfig>;
export const shortcutsStore = map<Shortcuts>({
toggleTerminal: {
@@ -27,13 +30,19 @@ export const shortcutsStore = map<Shortcuts>({
},
});
export const settingsStore = map<Settings>({
shortcuts: shortcutsStore.get(),
const initialProviderSettings: ProviderSetting = {};
PROVIDER_LIST.forEach((provider) => {
initialProviderSettings[provider.name] = {
...provider,
settings: {
enabled: true,
},
};
});
export const providersStore = map<ProviderSetting>(initialProviderSettings);
shortcutsStore.subscribe((shortcuts) => {
settingsStore.set({
...settingsStore.get(),
shortcuts,
});
});
export const isDebugMode = atom(false);
export const isEventLogsEnabled = atom(false);
export const isLocalModelsEnabled = atom(true);

View File

@@ -1,4 +1,5 @@
import { atom } from 'nanostores';
import { logStore } from './logs';
export type Theme = 'dark' | 'light';
@@ -26,10 +27,8 @@ function initStore() {
export function toggleTheme() {
const currentTheme = themeStore.get();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
themeStore.set(newTheme);
logStore.logSystem(`Theme changed to ${newTheme} mode`);
localStorage.setItem(kTheme, newTheme);
document.querySelector('html')?.setAttribute('data-theme', newTheme);
}

View File

@@ -15,10 +15,12 @@ import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import * as nodePath from 'node:path';
import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
export interface ArtifactState {
id: string;
title: string;
type?: string;
closed: boolean;
runner: ActionRunner;
}
@@ -230,7 +232,7 @@ export class WorkbenchStore {
// TODO: what do we wanna do and how do we wanna recover from this?
}
addArtifact({ messageId, title, id }: ArtifactCallbackData) {
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
const artifact = this.#getArtifact(messageId);
if (artifact) {
@@ -245,6 +247,7 @@ export class WorkbenchStore {
id,
title,
closed: false,
type,
runner: new ActionRunner(webcontainer, () => this.boltTerminal),
});
}
@@ -394,15 +397,14 @@ export class WorkbenchStore {
return syncedFiles;
}
async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) {
async pushToGitHub(repoName: string, githubUsername?: string, ghToken?: string) {
try {
// Get the GitHub auth token from environment variables
const githubToken = ghToken;
// Use cookies if username and token are not provided
const githubToken = ghToken || Cookies.get('githubToken');
const owner = githubUsername || Cookies.get('githubUsername');
const owner = githubUsername;
if (!githubToken) {
throw new Error('GitHub token is not set in environment variables');
if (!githubToken || !owner) {
throw new Error('GitHub token or username is not set in cookies or provided.');
}
// Initialize Octokit with the auth token
@@ -499,7 +501,8 @@ export class WorkbenchStore {
alert(`Repository created and code pushed: ${repo.html_url}`);
} catch (error) {
console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error));
console.error('Error pushing to GitHub:', error);
throw error; // Rethrow the error for further handling
}
}
}

View File

@@ -78,6 +78,23 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
import { logStore } from './lib/stores/logs';
export default function App() {
return <Outlet />;
const theme = useStore(themeStore);
useEffect(() => {
logStore.logSystem('Application initialized', {
theme,
platform: navigator.platform,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
});
}, []);
return (
<Layout>
<Outlet />
</Layout>
);
}

View File

@@ -3,6 +3,7 @@ import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
export const meta: MetaFunction = () => {
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
@@ -12,7 +13,8 @@ export const loader = () => json({});
export default function Index() {
return (
<div className="flex flex-col h-full w-full">
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
</div>

View File

@@ -1,18 +1,16 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck TODO: Provider proper types
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
import type { IProviderSetting } from '~/types/model';
export async function action(args: ActionFunctionArgs) {
return chatAction(args);
}
function parseCookies(cookieHeader) {
const cookies = {};
function parseCookies(cookieHeader: string) {
const cookies: any = {};
// Split the cookie string by semicolons and spaces
const items = cookieHeader.split(';').map((cookie) => cookie.trim());
@@ -32,7 +30,7 @@ function parseCookies(cookieHeader) {
}
async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages, model } = await request.json<{
const { messages } = await request.json<{
messages: Messages;
model: string;
}>();
@@ -40,15 +38,16 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const cookieHeader = request.headers.get('Cookie');
// Parse the cookie's value (returns an object or null if no cookie exists)
const apiKeys = JSON.parse(parseCookies(cookieHeader).apiKeys || '{}');
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
const providerSettings: Record<string, IProviderSetting> = JSON.parse(
parseCookies(cookieHeader || '').providers || '{}',
);
const stream = new SwitchableStream();
try {
const options: StreamingOptions = {
toolChoice: 'none',
apiKeys,
model,
onFinish: async ({ text: content, finishReason }) => {
if (finishReason !== 'length') {
return stream.close();
@@ -65,13 +64,13 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
messages.push({ role: 'assistant', content });
messages.push({ role: 'user', content: CONTINUE_PROMPT });
const result = await streamText(messages, context.cloudflare.env, options);
const result = await streamText({ messages, env: context.cloudflare.env, options, apiKeys, providerSettings });
return stream.switchSource(result.toAIStream());
},
};
const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
const result = await streamText({ messages, env: context.cloudflare.env, options, apiKeys, providerSettings });
stream.switchSource(result.toAIStream());
@@ -81,7 +80,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
contentType: 'text/plain; charset=utf-8',
},
});
} catch (error) {
} catch (error: any) {
console.log(error);
if (error.message?.includes('API key')) {

View File

@@ -2,7 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { StreamingTextResponse, parseStreamPart } from 'ai';
import { streamText } from '~/lib/.server/llm/stream-text';
import { stripIndents } from '~/utils/stripIndent';
import type { ProviderInfo } from '~/types/model';
import type { IProviderSetting, ProviderInfo } from '~/types/model';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -11,8 +11,28 @@ export async function action(args: ActionFunctionArgs) {
return enhancerAction(args);
}
function parseCookies(cookieHeader: string) {
const cookies: any = {};
// Split the cookie string by semicolons and spaces
const items = cookieHeader.split(';').map((cookie) => cookie.trim());
items.forEach((item) => {
const [name, ...rest] = item.split('=');
if (name && rest) {
// Decode the name and value, and join value parts in case it contains '='
const decodedName = decodeURIComponent(name.trim());
const decodedValue = decodeURIComponent(rest.join('=').trim());
cookies[decodedName] = decodedValue;
}
});
return cookies;
}
async function enhancerAction({ context, request }: ActionFunctionArgs) {
const { message, model, provider, apiKeys } = await request.json<{
const { message, model, provider } = await request.json<{
message: string;
model: string;
provider: ProviderInfo;
@@ -36,9 +56,17 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
});
}
const cookieHeader = request.headers.get('Cookie');
// Parse the cookie's value (returns an object or null if no cookie exists)
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
const providerSettings: Record<string, IProviderSetting> = JSON.parse(
parseCookies(cookieHeader || '').providers || '{}',
);
try {
const result = await streamText(
[
const result = await streamText({
messages: [
{
role: 'user',
content:
@@ -73,10 +101,10 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
`,
},
],
context.cloudflare.env,
undefined,
env: context.cloudflare.env,
apiKeys,
);
providerSettings,
});
const transformStream = new TransformStream({
transform(chunk, controller) {

23
app/routes/git.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json, type MetaFunction } from '@remix-run/cloudflare';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { GitUrlImport } from '~/components/git/GitUrlImport.client';
import { Header } from '~/components/header/Header';
export const meta: MetaFunction = () => {
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
};
export async function loader(args: LoaderFunctionArgs) {
return json({ url: args.params.url });
}
export default function Index() {
return (
<div className="flex flex-col h-full w-full">
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <GitUrlImport />}</ClientOnly>
</div>
);
}

View File

@@ -12,3 +12,13 @@ body {
height: 100%;
width: 100%;
}
:root {
--gradient-opacity: 0.8;
--primary-color: rgba(158, 117, 240, var(--gradient-opacity));
--secondary-color: rgba(138, 43, 226, var(--gradient-opacity));
--accent-color: rgba(128, 59, 239, var(--gradient-opacity));
// --primary-color: rgba(147, 112, 219, var(--gradient-opacity));
// --secondary-color: rgba(138, 43, 226, var(--gradient-opacity));
// --accent-color: rgba(180, 170, 220, var(--gradient-opacity));
}

View File

@@ -1,4 +1,5 @@
export interface BoltArtifactData {
id: string;
title: string;
type?: string | undefined;
}

View File

@@ -3,3 +3,11 @@ interface Window {
webkitSpeechRecognition: typeof SpeechRecognition;
SpeechRecognition: typeof SpeechRecognition;
}
interface Performance {
memory?: {
jsHeapSizeLimit: number;
totalJSHeapSize: number;
usedJSHeapSize: number;
};
}

View File

@@ -3,8 +3,17 @@ import type { ModelInfo } from '~/utils/types';
export type ProviderInfo = {
staticModels: ModelInfo[];
name: string;
getDynamicModels?: () => Promise<ModelInfo[]>;
getDynamicModels?: (apiKeys?: Record<string, string>, providerSettings?: IProviderSetting) => Promise<ModelInfo[]>;
getApiKeyLink?: string;
labelForGetApiKey?: string;
icon?: string;
};
export interface IProviderSetting {
enabled?: boolean;
baseUrl?: string;
}
export type IProviderConfig = ProviderInfo & {
settings: IProviderSetting;
};

View File

@@ -1,5 +1,8 @@
import Cookies from 'js-cookie';
import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
import type { ProviderInfo } from '~/types/model';
import type { ProviderInfo, IProviderSetting } from '~/types/model';
import { createScopedLogger } from './logger';
import { logStore } from '~/lib/stores/logs';
export const WORK_DIR_NAME = 'project';
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
@@ -9,6 +12,8 @@ export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;
export const DEFAULT_MODEL = 'claude-3-5-sonnet-latest';
export const PROMPT_COOKIE_KEY = 'cachedPrompt';
const logger = createScopedLogger('Constants');
const PROVIDER_LIST: ProviderInfo[] = [
{
name: 'Anthropic',
@@ -122,11 +127,12 @@ const PROVIDER_LIST: ProviderInfo[] = [
name: 'Google',
staticModels: [
{ name: 'gemini-1.5-flash-latest', label: 'Gemini 1.5 Flash', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-flash-002', label: 'Gemini 1.5 Flash-002', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-flash-8b', label: 'Gemini 1.5 Flash-8b', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-pro-latest', label: 'Gemini 1.5 Pro', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-1.5-pro-002', label: 'Gemini 1.5 Pro-002', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-exp-1121', label: 'Gemini exp-1121', provider: 'Google', maxTokenAllowed: 8192 },
{ name: 'gemini-exp-1206', label: 'Gemini exp-1206', provider: 'Google', maxTokenAllowed: 8192 },
],
getApiKeyLink: 'https://aistudio.google.com/app/apikey',
},
@@ -262,6 +268,7 @@ const PROVIDER_LIST: ProviderInfo[] = [
},
{
name: 'Together',
getDynamicModels: getTogetherModels,
staticModels: [
{
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
@@ -293,8 +300,66 @@ const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat(
export let MODEL_LIST: ModelInfo[] = [...staticModels];
const getOllamaBaseUrl = () => {
const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
export async function getModelList(
apiKeys: Record<string, string>,
providerSettings?: Record<string, IProviderSetting>,
) {
MODEL_LIST = [
...(
await Promise.all(
PROVIDER_LIST.filter(
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])),
)
).flat(),
...staticModels,
];
return MODEL_LIST;
}
async function getTogetherModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
try {
const baseUrl = settings?.baseUrl || import.meta.env.TOGETHER_API_BASE_URL || '';
const provider = 'Together';
if (!baseUrl) {
return [];
}
let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
if (apiKeys && apiKeys[provider]) {
apiKey = apiKeys[provider];
}
if (!apiKey) {
return [];
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data: any[] = (res || []).filter((model: any) => model.type == 'chat');
return data.map((m: any) => ({
name: m.id,
label: `${m.display_name} - in:$${m.pricing.input.toFixed(
2,
)} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
provider,
maxTokenAllowed: 8000,
}));
} catch (e) {
console.error('Error getting OpenAILike models:', e);
return [];
}
}
const getOllamaBaseUrl = (settings?: IProviderSetting) => {
const defaultBaseUrl = settings?.baseUrl || import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
// Check if we're in the browser
if (typeof window !== 'undefined') {
@@ -308,15 +373,9 @@ const getOllamaBaseUrl = () => {
return isDocker ? defaultBaseUrl.replace('localhost', 'host.docker.internal') : defaultBaseUrl;
};
async function getOllamaModels(): Promise<ModelInfo[]> {
/*
* if (typeof window === 'undefined') {
* return [];
* }
*/
async function getOllamaModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
try {
const baseUrl = getOllamaBaseUrl();
const baseUrl = getOllamaBaseUrl(settings);
const response = await fetch(`${baseUrl}/api/tags`);
const data = (await response.json()) as OllamaApiResponse;
@@ -326,21 +385,31 @@ async function getOllamaModels(): Promise<ModelInfo[]> {
provider: 'Ollama',
maxTokenAllowed: 8000,
}));
} catch (e) {
console.error('Error getting Ollama models:', e);
} catch (e: any) {
logStore.logError('Failed to get Ollama models', e, { baseUrl: settings?.baseUrl });
logger.warn('Failed to get Ollama models: ', e.message || '');
return [];
}
}
async function getOpenAILikeModels(): Promise<ModelInfo[]> {
async function getOpenAILikeModels(
apiKeys?: Record<string, string>,
settings?: IProviderSetting,
): Promise<ModelInfo[]> {
try {
const baseUrl = import.meta.env.OPENAI_LIKE_API_BASE_URL || '';
const baseUrl = settings?.baseUrl || import.meta.env.OPENAI_LIKE_API_BASE_URL || '';
if (!baseUrl) {
return [];
}
const apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
let apiKey = '';
if (apiKeys && apiKeys.OpenAILike) {
apiKey = apiKeys.OpenAILike;
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
@@ -392,13 +461,10 @@ async function getOpenRouterModels(): Promise<ModelInfo[]> {
}));
}
async function getLMStudioModels(): Promise<ModelInfo[]> {
if (typeof window === 'undefined') {
return [];
}
async function getLMStudioModels(_apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
try {
const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
const baseUrl = settings?.baseUrl || import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
const response = await fetch(`${baseUrl}/v1/models`);
const data = (await response.json()) as any;
@@ -407,23 +473,42 @@ async function getLMStudioModels(): Promise<ModelInfo[]> {
label: model.id,
provider: 'LMStudio',
}));
} catch (e) {
console.error('Error getting LMStudio models:', e);
} catch (e: any) {
logStore.logError('Failed to get LMStudio models', e, { baseUrl: settings?.baseUrl });
logger.warn('Failed to get LMStudio models: ', e.message || '');
return [];
}
}
async function initializeModelList(): Promise<ModelInfo[]> {
async function initializeModelList(providerSettings?: Record<string, IProviderSetting>): Promise<ModelInfo[]> {
let apiKeys: Record<string, string> = {};
try {
const storedApiKeys = Cookies.get('apiKeys');
if (storedApiKeys) {
const parsedKeys = JSON.parse(storedApiKeys);
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
apiKeys = parsedKeys;
}
}
} catch (error: any) {
logStore.logError('Failed to fetch API keys from cookies', error);
logger.warn(`Failed to fetch apikeys from cookies: ${error?.message}`);
}
MODEL_LIST = [
...(
await Promise.all(
PROVIDER_LIST.filter(
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
).map((p) => p.getDynamicModels()),
).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])),
)
).flat(),
...staticModels,
];
return MODEL_LIST;
}

105
app/utils/fileUtils.ts Normal file
View File

@@ -0,0 +1,105 @@
import ignore from 'ignore';
// Common patterns to ignore, similar to .gitignore
export 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*',
];
export const MAX_FILES = 1000;
export const ig = ignore().add(IGNORE_PATTERNS);
export const generateId = () => Math.random().toString(36).substring(2, 15);
export const isBinaryFile = async (file: File): Promise<boolean> => {
const chunkSize = 1024;
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;
}
}
return false;
};
export const shouldIncludeFile = (path: string): boolean => {
return !ig.ignores(path);
};
const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string, string> } | null> => {
const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json'));
if (!packageJsonFile) {
return null;
}
try {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsText(packageJsonFile);
});
return JSON.parse(content);
} catch (error) {
console.error('Error reading package.json:', error);
return null;
}
};
export const detectProjectType = async (
files: File[],
): Promise<{ type: string; setupCommand: string; followupMessage: string }> => {
const hasFile = (name: string) => files.some((f) => f.webkitRelativePath.endsWith(name));
if (hasFile('package.json')) {
const packageJson = await readPackageJson(files);
const scripts = packageJson?.scripts || {};
// Check for preferred commands in priority order
const preferredCommands = ['dev', 'start', 'preview'];
const availableCommand = preferredCommands.find((cmd) => scripts[cmd]);
if (availableCommand) {
return {
type: 'Node.js',
setupCommand: `npm install && npm run ${availableCommand}`,
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`,
};
}
return {
type: 'Node.js',
setupCommand: 'npm install',
followupMessage:
'Would you like me to inspect package.json to determine the available scripts for running this project?',
};
}
if (hasFile('index.html')) {
return {
type: 'Static',
setupCommand: 'npx --yes serve',
followupMessage: '',
};
}
return { type: '', setupCommand: '', followupMessage: '' };
};

68
app/utils/folderImport.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { Message } from 'ai';
import { generateId } from './fileUtils';
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
export const createChatFromFolder = async (
files: File[],
binaryFiles: string[],
folderName: string,
): Promise<Message[]> => {
const fileArtifacts = await Promise.all(
files.map(async (file) => {
return new Promise<{ content: string; path: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const content = reader.result as string;
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
resolve({
content,
path: relativePath,
});
};
reader.onerror = reject;
reader.readAsText(file);
});
}),
);
const commands = await detectProjectCommands(fileArtifacts);
const commandsMessage = createCommandsMessage(commands);
const binaryFilesMessage =
binaryFiles.length > 0
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const filesMessage: Message = {
role: 'assistant',
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
<boltArtifact id="imported-files" title="Imported Files">
${fileArtifacts
.map(
(file) => `<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
const userMessage: Message = {
role: 'user',
id: generateId(),
content: `Import the "${folderName}" folder`,
createdAt: new Date(),
};
const messages = [userMessage, filesMessage];
if (commandsMessage) {
messages.push(commandsMessage);
}
return messages;
};

View File

@@ -0,0 +1,80 @@
import type { Message } from 'ai';
import { generateId } from './fileUtils';
export interface ProjectCommands {
type: string;
setupCommand: string;
followupMessage: string;
}
interface FileContent {
content: string;
path: string;
}
export async function detectProjectCommands(files: FileContent[]): Promise<ProjectCommands> {
const hasFile = (name: string) => files.some((f) => f.path.endsWith(name));
if (hasFile('package.json')) {
const packageJsonFile = files.find((f) => f.path.endsWith('package.json'));
if (!packageJsonFile) {
return { type: '', setupCommand: '', followupMessage: '' };
}
try {
const packageJson = JSON.parse(packageJsonFile.content);
const scripts = packageJson?.scripts || {};
// Check for preferred commands in priority order
const preferredCommands = ['dev', 'start', 'preview'];
const availableCommand = preferredCommands.find((cmd) => scripts[cmd]);
if (availableCommand) {
return {
type: 'Node.js',
setupCommand: `npm install && npm run ${availableCommand}`,
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`,
};
}
return {
type: 'Node.js',
setupCommand: 'npm install',
followupMessage:
'Would you like me to inspect package.json to determine the available scripts for running this project?',
};
} catch (error) {
console.error('Error parsing package.json:', error);
return { type: '', setupCommand: '', followupMessage: '' };
}
}
if (hasFile('index.html')) {
return {
type: 'Static',
setupCommand: 'npx --yes serve',
followupMessage: '',
};
}
return { type: '', setupCommand: '', followupMessage: '' };
}
export function createCommandsMessage(commands: ProjectCommands): Message | null {
if (!commands.setupCommand) {
return null;
}
return {
role: 'assistant',
content: `
<boltArtifact id="project-setup" title="Project Setup">
<boltAction type="shell">
${commands.setupCommand}
</boltAction>
</boltArtifact>${commands.followupMessage ? `\n\n${commands.followupMessage}` : ''}`,
id: generateId(),
createdAt: new Date(),
};
}

View File

@@ -26,12 +26,3 @@ export interface ModelInfo {
provider: string;
maxTokenAllowed: number;
}
export interface ProviderInfo {
staticModels: ModelInfo[];
name: string;
getDynamicModels?: () => Promise<ModelInfo[]>;
getApiKeyLink?: string;
labelForGetApiKey?: string;
icon?: string;
}