This commit is contained in:
eduardruzga
2024-12-14 19:48:37 +02:00
92 changed files with 4443 additions and 1152 deletions

View File

@@ -1 +1 @@
{ "commit": "5b6b26bc9ce287e6e351ca443ad0f411d1371a7f" }
{ "commit": "fcb61ba49990d1d0cc13d05a0958007b6e9c1c38" }

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,7 +17,6 @@ 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';
@@ -26,6 +25,8 @@ 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,
@@ -108,46 +111,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
const [transcript, setTranscript] = useState('');
// Load enabled providers from cookies
const [enabledProviders, setEnabledProviders] = useState(() => {
const savedProviders = Cookies.get('providers');
if (savedProviders) {
try {
const parsedProviders = JSON.parse(savedProviders);
return PROVIDER_LIST.filter((p) => parsedProviders[p.name]);
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
return PROVIDER_LIST;
}
}
return PROVIDER_LIST;
});
// Update enabled providers when cookies change
useEffect(() => {
const updateProvidersFromCookies = () => {
const savedProviders = Cookies.get('providers');
console.log(transcript);
}, [transcript]);
if (savedProviders) {
try {
const parsedProviders = JSON.parse(savedProviders);
setEnabledProviders(PROVIDER_LIST.filter((p) => parsedProviders[p.name]));
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
}
}
};
updateProvidersFromCookies();
const interval = setInterval(updateProvidersFromCookies, 1000);
return () => clearInterval(interval);
}, [PROVIDER_LIST]);
console.log(transcript);
useEffect(() => {
// Load API keys from cookies on component mount
try {
@@ -167,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);
});
@@ -291,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>
@@ -353,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>
@@ -377,10 +353,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
modelList={modelList}
provider={provider}
setProvider={setProvider}
providerList={PROVIDER_LIST}
providerList={providerList || PROVIDER_LIST}
apiKeys={apiKeys}
/>
{enabledProviders.length > 0 && provider && (
{(providerList || []).length > 0 && provider && (
<APIKeyManager
provider={provider}
apiKey={apiKeys[provider.name] || ''}
@@ -401,6 +377,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
}}
/>
<ClientOnly>
{() => (
<ScreenshotStateManager
setUploadedFiles={setUploadedFiles}
setImageDataList={setImageDataList}
uploadedFiles={uploadedFiles}
imageDataList={imageDataList}
/>
)}
</ClientOnly>
<div
className={classNames(
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
@@ -409,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',
)}
@@ -456,6 +442,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
return;
}
// ignore if using input method engine
if (event.nativeEvent.isComposing) {
return;
}
handleSendMessage?.(event);
}
}}
@@ -476,7 +467,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<SendButton
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
isStreaming={isStreaming}
disabled={enabledProviders.length === 0}
disabled={!providerList || providerList.length === 0}
onClick={(event) => {
if (isStreaming) {
handleStop?.();
@@ -536,7 +527,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
!isModelSettingsCollapsed,
})}
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
disabled={enabledProviders.length === 0}
disabled={!providerList || providerList.length === 0}
>
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
{isModelSettingsCollapsed ? <span className="text-xs">{model}</span> : <span />}

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,8 @@ 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 files = useStore(workbenchStore.files);
const { activeProviders } = useSettings();
const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
@@ -111,6 +114,7 @@ export const ChatImpl = memo(
api: '/api/chat',
body: {
apiKeys,
files,
},
sendExtraMessageFields: true,
onError: (error) => {
@@ -325,6 +329,7 @@ export const ChatImpl = memo(
setModel={handleModelChange}
provider={provider}
setProvider={handleProviderChange}
providerList={activeProviders}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={(e) => {

View File

@@ -1,7 +1,8 @@
import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import WithTooltip from '~/components/ui/Tooltip';
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
import { generateId } from '~/utils/fileUtils';
const IGNORE_PATTERNS = [
'node_modules/**',
@@ -28,7 +29,6 @@ const IGNORE_PATTERNS = [
];
const ig = ignore().add(IGNORE_PATTERNS);
const generateId = () => Math.random().toString(36).substring(2, 15);
interface GitCloneButtonProps {
className?: string;
@@ -52,52 +52,59 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8');
const message: Message = {
// 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" >
${filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
if (encoding === 'utf8') {
return `<boltAction type="file" filePath="${filePath}">
${content}
</boltAction>`;
} else if (content instanceof Uint8Array) {
return `<boltAction type="file" filePath="${filePath}">
${textDecoder.decode(content)}
</boltAction>`;
} else {
return '';
}
})
.join('\n')}
</boltArtifact>`,
<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(),
};
console.log(JSON.stringify(message));
importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, [message]);
const messages = [filesMessage];
// console.log(files);
if (commandsMessage) {
messages.push(commandsMessage);
}
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
}
};
return (
<WithTooltip tooltip="Clone A Git Repo">
<button
onClick={(e) => {
onClick(e);
}}
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>
</WithTooltip>
<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

@@ -3,6 +3,7 @@ import type { Message } from 'ai';
import { toast } from 'react-toastify';
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;
@@ -16,9 +17,15 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
const allFiles = Array.from(e.target.files || []);
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;
}
@@ -31,7 +38,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
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;
}
@@ -48,11 +58,18 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
.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`);
}
@@ -62,8 +79,14 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
await importChat(folderName, [...messages]);
}
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 {

View File

@@ -1,7 +1,6 @@
import type { ProviderInfo } from '~/types/model';
import type { ModelInfo } from '~/utils/types';
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { useEffect } from 'react';
interface ModelSelectorProps {
model?: string;
@@ -22,62 +21,28 @@ export const ModelSelector = ({
providerList,
}: ModelSelectorProps) => {
// Load enabled providers from cookies
const [enabledProviders, setEnabledProviders] = useState(() => {
const savedProviders = Cookies.get('providers');
if (savedProviders) {
try {
const parsedProviders = JSON.parse(savedProviders);
return providerList.filter((p) => parsedProviders[p.name]);
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
return providerList;
}
}
return providerList;
});
// Update enabled providers when cookies change
useEffect(() => {
// Function to update providers from cookies
const updateProvidersFromCookies = () => {
const savedProviders = Cookies.get('providers');
// If current provider is disabled, switch to first enabled provider
if (providerList.length == 0) {
return;
}
if (savedProviders) {
try {
const parsedProviders = JSON.parse(savedProviders);
const newEnabledProviders = providerList.filter((p) => parsedProviders[p.name]);
setEnabledProviders(newEnabledProviders);
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
const firstEnabledProvider = providerList[0];
setProvider?.(firstEnabledProvider);
// If current provider is disabled, switch to first enabled provider
if (provider && !parsedProviders[provider.name] && newEnabledProviders.length > 0) {
const firstEnabledProvider = newEnabledProviders[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);
// 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);
}
}
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
}
if (firstModel) {
setModel?.(firstModel.name);
}
};
// Initial update
updateProvidersFromCookies();
// Set up an interval to check for cookie changes
const interval = setInterval(updateProvidersFromCookies, 1000);
return () => clearInterval(interval);
}
}, [providerList, provider, setProvider, modelList, setModel]);
if (enabledProviders.length === 0) {
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">
@@ -93,7 +58,7 @@ export const ModelSelector = ({
<select
value={provider?.name ?? ''}
onChange={(e) => {
const newProvider = enabledProviders.find((p: ProviderInfo) => p.name === e.target.value);
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
if (newProvider && setProvider) {
setProvider(newProvider);
@@ -107,7 +72,7 @@ export const ModelSelector = ({
}}
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"
>
{enabledProviders.map((provider: ProviderInfo) => (
{providerList.map((provider: ProviderInfo) => (
<option key={provider.name} value={provider.name}>
{provider.name}
</option>
@@ -121,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

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
interface ScreenshotStateManagerProps {
setUploadedFiles?: (files: File[]) => void;
setImageDataList?: (dataList: string[]) => void;
uploadedFiles: File[];
imageDataList: string[];
}
export const ScreenshotStateManager = ({
setUploadedFiles,
setImageDataList,
uploadedFiles,
imageDataList,
}: ScreenshotStateManagerProps) => {
useEffect(() => {
if (setUploadedFiles && setImageDataList) {
(window as any).__BOLT_SET_UPLOADED_FILES__ = setUploadedFiles;
(window as any).__BOLT_SET_IMAGE_DATA_LIST__ = setImageDataList;
(window as any).__BOLT_UPLOADED_FILES__ = uploadedFiles;
(window as any).__BOLT_IMAGE_DATA_LIST__ = imageDataList;
}
return () => {
delete (window as any).__BOLT_SET_UPLOADED_FILES__;
delete (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
delete (window as any).__BOLT_UPLOADED_FILES__;
delete (window as any).__BOLT_IMAGE_DATA_LIST__;
};
}, [setUploadedFiles, setImageDataList, uploadedFiles, imageDataList]);
return null;
};

View File

@@ -1,6 +1,5 @@
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) {

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,54 @@
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!');
Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
};
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,7 +3,7 @@ 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 { Settings } from '~/components/ui/Settings';
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';
@@ -35,6 +35,25 @@ 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);
@@ -126,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"
@@ -208,7 +226,7 @@ export const Menu = () => {
<ThemeSwitch />
</div>
</div>
<Settings open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
<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

@@ -1,395 +0,0 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { useState } from 'react';
import { classNames } from '~/utils/classNames';
import { DialogTitle, dialogVariants, dialogBackdropVariants } from './Dialog';
import { IconButton } from './IconButton';
import { providersList } from '~/lib/stores/settings';
import { db, getAll, deleteById } from '~/lib/persistence';
import { toast } from 'react-toastify';
import { useNavigate } from '@remix-run/react';
import commit from '~/commit.json';
import Cookies from 'js-cookie';
interface SettingsProps {
open: boolean;
onClose: () => void;
}
type TabType = 'chat-history' | 'providers' | 'features' | 'debug';
// Providers that support base URL configuration
const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
export const Settings = ({ open, onClose }: SettingsProps) => {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('chat-history');
const [isDebugEnabled, setIsDebugEnabled] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
// Load base URLs from cookies
const [baseUrls, setBaseUrls] = useState(() => {
const savedUrls = Cookies.get('providerBaseUrls');
if (savedUrls) {
try {
return JSON.parse(savedUrls);
} catch (error) {
console.error('Failed to parse base URLs from cookies:', error);
return {
Ollama: 'http://localhost:11434',
LMStudio: 'http://localhost:1234',
OpenAILike: '',
};
}
}
return {
Ollama: 'http://localhost:11434',
LMStudio: 'http://localhost:1234',
OpenAILike: '',
};
});
const handleBaseUrlChange = (provider: string, url: string) => {
setBaseUrls((prev: Record<string, string>) => {
const newUrls = { ...prev, [provider]: url };
Cookies.set('providerBaseUrls', JSON.stringify(newUrls));
return newUrls;
});
};
const tabs: { id: TabType; label: string; icon: string }[] = [
{ id: 'chat-history', label: 'Chat History', icon: 'i-ph:book' },
{ id: 'providers', label: 'Providers', icon: 'i-ph:key' },
{ id: 'features', label: 'Features', icon: 'i-ph:star' },
...(isDebugEnabled ? [{ id: 'debug' as TabType, label: 'Debug Tab', icon: 'i-ph:bug' }] : []),
];
// Load providers from cookies on mount
const [providers, setProviders] = useState(() => {
const savedProviders = Cookies.get('providers');
if (savedProviders) {
try {
const parsedProviders = JSON.parse(savedProviders);
// Merge saved enabled states with the base provider list
return providersList.map((provider) => ({
...provider,
isEnabled: parsedProviders[provider.name] || false,
}));
} catch (error) {
console.error('Failed to parse providers from cookies:', error);
}
}
return providersList;
});
const handleToggleProvider = (providerName: string) => {
setProviders((prevProviders) => {
const newProviders = prevProviders.map((provider) =>
provider.name === providerName ? { ...provider, isEnabled: !provider.isEnabled } : provider,
);
// Save to cookies
const enabledStates = newProviders.reduce(
(acc, provider) => ({
...acc,
[provider.name]: provider.isEnabled,
}),
{},
);
Cookies.set('providers', JSON.stringify(enabledStates));
return newProviders;
});
};
const filteredProviders = providers
.filter((provider) => provider.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
const handleCopyToClipboard = () => {
const debugInfo = {
OS: navigator.platform,
Browser: navigator.userAgent,
ActiveFeatures: providers.filter((provider) => provider.isEnabled).map((provider) => provider.name),
BaseURLs: {
Ollama: process.env.REACT_APP_OLLAMA_URL,
OpenAI: process.env.REACT_APP_OPENAI_URL,
LMStudio: process.env.REACT_APP_LM_STUDIO_URL,
},
Version: versionHash,
};
navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
alert('Debug information copied to clipboard!');
});
};
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) {
toast.error('Database is not available');
return;
}
try {
setIsDeleting(true);
const allChats = await getAll(db);
// Delete all chats one by one
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
toast.success('All chats deleted successfully');
navigate('/', { replace: true });
} catch (error) {
toast.error('Failed to delete chats');
console.error(error);
} finally {
setIsDeleting(false);
}
};
const handleExportAllChats = async () => {
if (!db) {
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`);
toast.success('Chats exported successfully');
} catch (error) {
toast.error('Failed to export chats');
console.error(error);
}
};
const versionHash = commit.commit; // Get the version hash from commit.json
return (
<RadixDialog.Root open={open}>
<RadixDialog.Portal>
<RadixDialog.Overlay asChild>
<motion.div
className="bg-black/50 fixed inset-0 z-max"
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 bg-gray-800 shadow-lg focus:outline-none overflow-hidden"
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
<div className="flex h-full">
<div className="w-48 border-r border-bolt-elements-borderColor bg-gray-700 p-4 flex flex-col justify-between">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={classNames(
'w-full flex items-center gap-2 px-4 py-3 rounded-lg text-left text-sm transition-all mb-2',
activeTab === tab.id ? 'bg-blue-600 text-white' : 'bg-gray-600 text-gray-200 hover:bg-blue-500',
)}
>
<div className={tab.icon} />
{tab.label}
</button>
))}
<div className="mt-auto flex flex-col gap-2">
<a
href="https://github.com/coleam00/bolt.new-any-llm"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center bg-blue-600 text-white rounded-lg py-2 hover:bg-blue-500 transition-colors duration-200"
>
GitHub
</a>
<a
href="https://coleam00.github.io/bolt.new-any-llm"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center bg-blue-600 text-white rounded-lg py-2 hover:bg-blue-500 transition-colors duration-200"
>
Docs
</a>
</div>
</div>
<div className="flex-1 flex flex-col p-8">
<DialogTitle className="flex-shrink-0 text-lg font-semibold text-white">Settings</DialogTitle>
<div className="flex-1 overflow-y-auto">
{activeTab === 'chat-history' && (
<div className="p-4">
<h3 className="text-lg font-medium text-white mb-4">Chat History</h3>
<button
onClick={handleExportAllChats}
className="bg-blue-500 text-white rounded-lg px-4 py-2 hover:bg-blue-600 mb-4 transition-colors duration-200"
>
Export All Chats
</button>
<div className="bg-red-500 text-white rounded-lg p-4 mb-4">
<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-red-700 text-white rounded-lg px-4 py-2 transition-colors duration-200',
isDeleting ? 'opacity-50 cursor-not-allowed' : 'hover:bg-red-800',
)}
>
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
</button>
</div>
</div>
)}
{activeTab === 'providers' && (
<div className="p-4">
<h3 className="text-lg font-medium text-white mb-4">Providers</h3>
<input
type="text"
placeholder="Search providers..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-4 p-2 rounded border border-gray-300 w-full"
/>
{filteredProviders.map((provider) => (
<div
key={provider.name}
className="flex flex-col mb-6 provider-item hover:bg-gray-600 p-4 rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<span className="text-white">{provider.name}</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={provider.isEnabled}
onChange={() => handleToggleProvider(provider.name)}
/>
<div className="w-11 h-6 bg-gray-300 rounded-full shadow-inner"></div>
<div
className={`absolute left-0 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
provider.isEnabled ? 'transform translate-x-full bg-green-500' : ''
}`}
></div>
</label>
</div>
{/* Base URL input for configurable providers */}
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.isEnabled && (
<div className="mt-2">
<label className="block text-sm text-gray-300 mb-1">Base URL:</label>
<input
type="text"
value={baseUrls[provider.name]}
onChange={(e) => handleBaseUrlChange(provider.name, e.target.value)}
placeholder={`Enter ${provider.name} base URL`}
className="w-full p-2 rounded border border-gray-600 bg-gray-700 text-white text-sm"
/>
</div>
)}
</div>
))}
</div>
)}
{activeTab === 'features' && (
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-white">Debug Info</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={isDebugEnabled}
onChange={() => setIsDebugEnabled(!isDebugEnabled)}
/>
<div className="w-11 h-6 bg-gray-300 rounded-full shadow-inner"></div>
<div
className={`absolute left-0 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
isDebugEnabled ? 'transform translate-x-full bg-green-500' : ''
}`}
></div>
</label>
</div>
<div className="feature-row">{/* Your feature content here */}</div>
</div>
)}
{activeTab === 'debug' && isDebugEnabled && (
<div className="p-4">
<h3 className="text-lg font-medium text-white mb-4">Debug Tab</h3>
<button
onClick={handleCopyToClipboard}
className="bg-blue-500 text-white rounded-lg px-4 py-2 hover:bg-blue-600 mb-4 transition-colors duration-200"
>
Copy to Clipboard
</button>
<h4 className="text-md font-medium text-white">System Information</h4>
<p className="text-white">OS: {navigator.platform}</p>
<p className="text-white">Browser: {navigator.userAgent}</p>
<h4 className="text-md font-medium text-white mt-4">Active Features</h4>
<ul>
{providers
.filter((provider) => provider.isEnabled)
.map((provider) => (
<li key={provider.name} className="text-white">
{provider.name}
</li>
))}
</ul>
<h4 className="text-md font-medium text-white mt-4">Base URLs</h4>
<ul>
<li className="text-white">Ollama: {process.env.REACT_APP_OLLAMA_URL}</li>
<li className="text-white">OpenAI: {process.env.REACT_APP_OPENAI_URL}</li>
<li className="text-white">LM Studio: {process.env.REACT_APP_LM_STUDIO_URL}</li>
</ul>
<h4 className="text-md font-medium text-white mt-4">Version Information</h4>
<p className="text-white">Version Hash: {versionHash}</p>
</div>
)}
</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

@@ -1,6 +1,5 @@
import { memo } from 'react';
import { IconButton } from './IconButton';
import { IconButton } from '~/components/ui/IconButton';
interface SettingsButtonProps {
onClick: () => void;
}

View File

@@ -1,62 +0,0 @@
import { motion } from 'framer-motion';
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
interface SliderOption<T> {
value: T;
text: string;
}
export interface SliderOptions<T> {
left: SliderOption<T>;
right: SliderOption<T>;
}
interface SettingsSliderProps<T> {
selected: T;
options: SliderOptions<T>;
setSelected?: (selected: T) => void;
}
export const SettingsSlider = memo(<T,>({ selected, options, setSelected }: SettingsSliderProps<T>) => {
const isLeftSelected = selected === options.left.value;
return (
<div className="relative flex items-center bg-bolt-elements-prompt-background rounded-lg">
<motion.div
className={classNames(
'absolute h-full bg-green-500 transition-all duration-300 rounded-lg',
isLeftSelected ? 'left-0 w-1/2' : 'right-0 w-1/2',
)}
initial={false}
animate={{
x: isLeftSelected ? 0 : '100%',
opacity: 0.2,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
/>
<button
onClick={() => setSelected?.(options.left.value)}
className={classNames(
'relative z-10 flex-1 p-2 rounded-lg text-sm transition-colors duration-200',
isLeftSelected ? 'text-white' : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
)}
>
{options.left.text}
</button>
<button
onClick={() => setSelected?.(options.right.value)}
className={classNames(
'relative z-10 flex-1 p-2 rounded-lg text-sm transition-colors duration-200',
!isLeftSelected ? 'text-white' : 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary',
)}
>
{options.right.text}
</button>
</div>
);
});

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

@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
import { ScreenshotSelector } from './ScreenshotSelector';
type ResizeSide = 'left' | 'right' | null;
@@ -20,6 +21,7 @@ export const Preview = memo(() => {
const [url, setUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
@@ -218,12 +220,17 @@ export const Preview = memo(() => {
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<IconButton
icon="i-ph:selection"
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
/>
<div
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
>
<input
title="URL"
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
@@ -281,7 +288,20 @@ export const Preview = memo(() => {
}}
>
{activePreview ? (
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
<>
<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>
)}

View File

@@ -0,0 +1,293 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
interface ScreenshotSelectorProps {
isSelectionMode: boolean;
setIsSelectionMode: (mode: boolean) => void;
containerRef: React.RefObject<HTMLElement>;
}
export const ScreenshotSelector = memo(
({ isSelectionMode, setIsSelectionMode, containerRef }: ScreenshotSelectorProps) => {
const [isCapturing, setIsCapturing] = useState(false);
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
// Cleanup function to stop all tracks when component unmounts
return () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
videoRef.current.remove();
videoRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
};
}, []);
const initializeStream = async () => {
if (!mediaStreamRef.current) {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: 'window',
preferCurrentTab: true,
surfaceSwitching: 'include',
systemAudio: 'exclude',
},
} as MediaStreamConstraints);
// Add handler for when sharing stops
stream.addEventListener('inactive', () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
videoRef.current.remove();
videoRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
setIsSelectionMode(false);
setSelectionStart(null);
setSelectionEnd(null);
setIsCapturing(false);
});
mediaStreamRef.current = stream;
// Initialize video element if needed
if (!videoRef.current) {
const video = document.createElement('video');
video.style.opacity = '0';
video.style.position = 'fixed';
video.style.pointerEvents = 'none';
video.style.zIndex = '-1';
document.body.appendChild(video);
videoRef.current = video;
}
// Set up video with the stream
videoRef.current.srcObject = stream;
await videoRef.current.play();
} catch (error) {
console.error('Failed to initialize stream:', error);
setIsSelectionMode(false);
toast.error('Failed to initialize screen capture');
}
}
return mediaStreamRef.current;
};
const handleCopySelection = useCallback(async () => {
if (!isSelectionMode || !selectionStart || !selectionEnd || !containerRef.current) {
return;
}
setIsCapturing(true);
try {
const stream = await initializeStream();
if (!stream || !videoRef.current) {
return;
}
// Wait for video to be ready
await new Promise((resolve) => setTimeout(resolve, 300));
// Create temporary canvas for full screenshot
const tempCanvas = document.createElement('canvas');
tempCanvas.width = videoRef.current.videoWidth;
tempCanvas.height = videoRef.current.videoHeight;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
throw new Error('Failed to get temporary canvas context');
}
// Draw the full video frame
tempCtx.drawImage(videoRef.current, 0, 0);
// Calculate scale factor between video and screen
const scaleX = videoRef.current.videoWidth / window.innerWidth;
const scaleY = videoRef.current.videoHeight / window.innerHeight;
// Get window scroll position
const scrollX = window.scrollX;
const scrollY = window.scrollY + 40;
// Get the container's position in the page
const containerRect = containerRef.current.getBoundingClientRect();
// Offset adjustments for more accurate clipping
const leftOffset = -9; // Adjust left position
const bottomOffset = -14; // Adjust bottom position
// Calculate the scaled coordinates with scroll offset and adjustments
const scaledX = Math.round(
(containerRect.left + Math.min(selectionStart.x, selectionEnd.x) + scrollX + leftOffset) * scaleX,
);
const scaledY = Math.round(
(containerRect.top + Math.min(selectionStart.y, selectionEnd.y) + scrollY + bottomOffset) * scaleY,
);
const scaledWidth = Math.round(Math.abs(selectionEnd.x - selectionStart.x) * scaleX);
const scaledHeight = Math.round(Math.abs(selectionEnd.y - selectionStart.y) * scaleY);
// Create final canvas for the cropped area
const canvas = document.createElement('canvas');
canvas.width = Math.round(Math.abs(selectionEnd.x - selectionStart.x));
canvas.height = Math.round(Math.abs(selectionEnd.y - selectionStart.y));
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
// Draw the cropped area
ctx.drawImage(tempCanvas, scaledX, scaledY, scaledWidth, scaledHeight, 0, 0, canvas.width, canvas.height);
// Convert to blob
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to create blob'));
}
}, 'image/png');
});
// Create a FileReader to convert blob to base64
const reader = new FileReader();
reader.onload = (e) => {
const base64Image = e.target?.result as string;
// Find the textarea element
const textarea = document.querySelector('textarea');
if (textarea) {
// Get the setters from the BaseChat component
const setUploadedFiles = (window as any).__BOLT_SET_UPLOADED_FILES__;
const setImageDataList = (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
const uploadedFiles = (window as any).__BOLT_UPLOADED_FILES__ || [];
const imageDataList = (window as any).__BOLT_IMAGE_DATA_LIST__ || [];
if (setUploadedFiles && setImageDataList) {
// Update the files and image data
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
setUploadedFiles([...uploadedFiles, file]);
setImageDataList([...imageDataList, base64Image]);
toast.success('Screenshot captured and added to chat');
} else {
toast.error('Could not add screenshot to chat');
}
}
};
reader.readAsDataURL(blob);
} catch (error) {
console.error('Failed to capture screenshot:', error);
toast.error('Failed to capture screenshot');
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
} finally {
setIsCapturing(false);
setSelectionStart(null);
setSelectionEnd(null);
setIsSelectionMode(false); // Turn off selection mode after capture
}
}, [isSelectionMode, selectionStart, selectionEnd, containerRef, setIsSelectionMode]);
const handleSelectionStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isSelectionMode) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setSelectionStart({ x, y });
setSelectionEnd({ x, y });
},
[isSelectionMode],
);
const handleSelectionMove = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isSelectionMode || !selectionStart) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setSelectionEnd({ x, y });
},
[isSelectionMode, selectionStart],
);
if (!isSelectionMode) {
return null;
}
return (
<div
className="absolute inset-0 cursor-crosshair"
onMouseDown={handleSelectionStart}
onMouseMove={handleSelectionMove}
onMouseUp={handleCopySelection}
onMouseLeave={() => {
if (selectionStart) {
setSelectionStart(null);
}
}}
style={{
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
userSelect: 'none',
WebkitUserSelect: 'none',
pointerEvents: 'all',
opacity: isCapturing ? 0 : 1,
zIndex: 50,
transition: 'opacity 0.1s ease-in-out',
}}
>
{selectionStart && selectionEnd && !isCapturing && (
<div
className="absolute border-2 border-blue-500 bg-blue-200 bg-opacity-20"
style={{
left: Math.min(selectionStart.x, selectionEnd.x),
top: Math.min(selectionStart.y, selectionEnd.y),
width: Math.abs(selectionEnd.x - selectionStart.x),
height: Math.abs(selectionEnd.y - selectionStart.y),
}}
/>
)}
</div>
);
},
);

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

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

@@ -3,6 +3,8 @@ import { getModel } from '~/lib/.server/llm/model';
import { MAX_TOKENS } from './constants';
import { getSystemPrompt } from './prompts';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import ignore from 'ignore';
import type { IProviderSetting } from '~/types/model';
interface ToolResult<Name extends string, Args, Result> {
toolCallId: string;
@@ -22,6 +24,78 @@ export type Messages = Message[];
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
export interface File {
type: 'file';
content: string;
isBinary: boolean;
}
export interface Folder {
type: 'folder';
}
type Dirent = File | Folder;
export type FileMap = Record<string, Dirent | undefined>;
export function simplifyBoltActions(input: string): string {
// Using regex to match boltAction tags that have type="file"
const regex = /(<boltAction[^>]*type="file"[^>]*>)([\s\S]*?)(<\/boltAction>)/g;
// Replace each matching occurrence
return input.replace(regex, (_0, openingTag, _2, closingTag) => {
return `${openingTag}\n ...\n ${closingTag}`;
});
}
// 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*',
'**/*lock.json',
'**/*lock.yml',
];
const ig = ignore().add(IGNORE_PATTERNS);
function createFilesContext(files: FileMap) {
let filePaths = Object.keys(files);
filePaths = filePaths.filter((x) => {
const relPath = x.replace('/home/project/', '');
return !ig.ignores(relPath);
});
const fileContexts = filePaths
.filter((x) => files[x] && files[x].type == 'file')
.map((path) => {
const dirent = files[path];
if (!dirent || dirent.type == 'folder') {
return '';
}
const codeWithLinesNumbers = dirent.content
.split('\n')
.map((v, i) => `${i + 1}|${v}`)
.join('\n');
return `<file path="${path}">\n${codeWithLinesNumbers}\n</file>`;
});
return `Below are the code files present in the webcontainer:\ncode format:\n<line number>|<line content>\n <codebase>${fileContexts.join('\n\n')}\n\n</codebase>`;
}
function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
const textContent = Array.isArray(message.content)
? message.content.find((item) => item.type === 'text')?.text || ''
@@ -58,15 +132,18 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid
return { model, provider, content: cleanedContent };
}
export async 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>;
files?: FileMap;
providerSettings?: Record<string, IProviderSetting>;
}) {
const { messages, env, options, apiKeys, files, providerSettings } = props;
let currentModel = DEFAULT_MODEL;
let currentProvider = DEFAULT_PROVIDER.name;
const MODEL_LIST = await getModelList(apiKeys || {});
const MODEL_LIST = await getModelList(apiKeys || {}, providerSettings);
const processedMessages = messages.map((message) => {
if (message.role === 'user') {
const { model, provider, content } = extractPropertiesFromMessage(message);
@@ -77,6 +154,12 @@ export async function streamText(
currentProvider = provider;
return { ...message, content };
} else if (message.role == 'assistant') {
const content = message.content;
// content = simplifyBoltActions(content);
return { ...message, content };
}
@@ -87,9 +170,17 @@ export async function streamText(
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
let systemPrompt = getSystemPrompt();
let codeContext = '';
if (files) {
codeContext = createFilesContext(files);
systemPrompt = `${systemPrompt}\n\n ${codeContext}`;
}
return _streamText({
model: getModel(currentProvider, currentModel, env, apiKeys) as any,
system: getSystemPrompt(),
model: getModel(currentProvider, currentModel, env, apiKeys, providerSettings) as any,
system: systemPrompt,
maxTokens: dynamicMaxTokens,
messages: convertToCoreMessages(processedMessages as any),
...options,

View File

@@ -23,14 +23,14 @@ const messageParser = new StreamingMessageParser({
logger.trace('onActionOpen', data.action);
// we only add shell actions when when the close tag got parsed because only then we have the content
if (data.action.type !== 'shell') {
if (data.action.type === 'file') {
workbenchStore.addAction(data);
}
},
onActionClose: (data) => {
logger.trace('onActionClose', data.action);
if (data.action.type === 'shell') {
if (data.action.type !== 'file') {
workbenchStore.addAction(data);
}

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);
});
}

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,32 +17,10 @@ export interface Shortcuts {
toggleTerminal: Shortcut;
}
export interface Provider {
name: string;
isEnabled: boolean;
}
export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama'];
export interface Settings {
shortcuts: Shortcuts;
providers: Provider[];
}
export const providersList: Provider[] = [
{ name: 'Groq', isEnabled: false },
{ name: 'HuggingFace', isEnabled: false },
{ name: 'OpenAI', isEnabled: false },
{ name: 'Anthropic', isEnabled: false },
{ name: 'OpenRouter', isEnabled: false },
{ name: 'Google', isEnabled: false },
{ name: 'Ollama', isEnabled: false },
{ name: 'OpenAILike', isEnabled: false },
{ name: 'Together', isEnabled: false },
{ name: 'Deepseek', isEnabled: false },
{ name: 'Mistral', isEnabled: false },
{ name: 'Cohere', isEnabled: false },
{ name: 'LMStudio', isEnabled: false },
{ name: 'xAI', isEnabled: false },
];
export type ProviderSetting = Record<string, IProviderConfig>;
export const shortcutsStore = map<Shortcuts>({
toggleTerminal: {
@@ -50,14 +30,19 @@ export const shortcutsStore = map<Shortcuts>({
},
});
export const settingsStore = map<Settings>({
shortcuts: shortcutsStore.get(),
providers: providersList,
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,6 +15,8 @@ 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';
import { createSampler } from '~/utils/sampler';
export interface ArtifactState {
id: string;
@@ -261,9 +263,9 @@ export class WorkbenchStore {
this.artifacts.setKey(messageId, { ...artifact, ...state });
}
addAction(data: ActionCallbackData) {
this._addAction(data);
// this._addAction(data);
// this.addToExecutionQueue(()=>this._addAction(data))
this.addToExecutionQueue(() => this._addAction(data));
}
async _addAction(data: ActionCallbackData) {
const { messageId } = data;
@@ -279,7 +281,7 @@ export class WorkbenchStore {
runAction(data: ActionCallbackData, isStreaming: boolean = false) {
if (isStreaming) {
this._runAction(data, isStreaming);
this.actionStreamSampler(data, isStreaming);
} else {
this.addToExecutionQueue(() => this._runAction(data, isStreaming));
}
@@ -293,6 +295,12 @@ export class WorkbenchStore {
unreachable('Artifact not found');
}
const action = artifact.runner.actions.get()[data.actionId];
if (!action || action.executed) {
return;
}
if (data.action.type === 'file') {
const wc = await webcontainer;
const fullPath = nodePath.join(wc.workdir, data.action.filePath);
@@ -322,6 +330,10 @@ export class WorkbenchStore {
}
}
actionStreamSampler = createSampler(async (data: ActionCallbackData, isStreaming: boolean = false) => {
return await this._runAction(data, isStreaming);
}, 100); // TODO: remove this magic number to have it configurable
#getArtifact(id: string) {
const artifacts = this.artifacts.get();
return artifacts[id];
@@ -396,15 +408,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
@@ -501,7 +512,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

@@ -3,6 +3,7 @@ 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);
@@ -27,13 +28,17 @@ function parseCookies(cookieHeader: string): Record<string, string> {
}
async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages } = await request.json<{
const { messages, files } = await request.json<{
messages: Messages;
model: string;
files: any;
}>();
const cookieHeader = request.headers.get('Cookie');
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
const providerSettings: Record<string, IProviderSetting> = JSON.parse(
parseCookies(cookieHeader || '').providers || '{}',
);
const stream = new SwitchableStream();
try {
@@ -57,13 +62,27 @@ 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, apiKeys);
const result = await streamText({
messages,
env: context.cloudflare.env,
options,
apiKeys,
files,
providerSettings,
});
return stream.switchSource(result.toDataStream());
},
};
const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
const result = await streamText({
messages,
env: context.cloudflare.env,
options,
apiKeys,
files,
providerSettings,
});
stream.switchSource(result.toDataStream());
return new Response(stream.readable, {

View File

@@ -3,7 +3,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();
@@ -12,8 +12,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;
@@ -37,9 +57,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:
@@ -74,10 +102,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

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

View File

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

View File

@@ -1,6 +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}`;
@@ -10,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',
@@ -123,22 +127,24 @@ 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',
},
{
name: 'Groq',
staticModels: [
{ name: 'llama-3.1-70b-versatile', label: 'Llama 3.1 70b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.1-8b-instant', label: 'Llama 3.1 8b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-11b-vision-preview', label: 'Llama 3.2 11b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-90b-vision-preview', label: 'Llama 3.2 90b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-3b-preview', label: 'Llama 3.2 3b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.2-1b-preview', label: 'Llama 3.2 1b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
{ name: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70b (Groq)', provider: 'Groq', maxTokenAllowed: 8000 },
],
getApiKeyLink: 'https://console.groq.com/keys',
},
@@ -295,13 +301,16 @@ const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat(
export let MODEL_LIST: ModelInfo[] = [...staticModels];
export async function getModelList(apiKeys: Record<string, string>) {
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)),
).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])),
)
).flat(),
...staticModels,
@@ -309,9 +318,9 @@ export async function getModelList(apiKeys: Record<string, string>) {
return MODEL_LIST;
}
async function getTogetherModels(apiKeys?: Record<string, string>): Promise<ModelInfo[]> {
async function getTogetherModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
try {
const baseUrl = import.meta.env.TOGETHER_API_BASE_URL || '';
const baseUrl = settings?.baseUrl || import.meta.env.TOGETHER_API_BASE_URL || '';
const provider = 'Together';
if (!baseUrl) {
@@ -350,8 +359,8 @@ async function getTogetherModels(apiKeys?: Record<string, string>): Promise<Mode
}
}
const getOllamaBaseUrl = () => {
const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
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') {
@@ -365,15 +374,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;
@@ -383,26 +386,29 @@ 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 [];
}
let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
let apiKey = '';
const apikeys = JSON.parse(Cookies.get('apiKeys') || '{}');
if (apikeys && apikeys.OpenAILike) {
apiKey = apikeys.OpenAILike;
if (apiKeys && apiKeys.OpenAILike) {
apiKey = apiKeys.OpenAILike;
}
const response = await fetch(`${baseUrl}/models`, {
@@ -456,13 +462,9 @@ 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;
@@ -471,13 +473,15 @@ 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 {
@@ -491,14 +495,15 @@ async function initializeModelList(): Promise<ModelInfo[]> {
}
}
} catch (error: any) {
console.warn(`Failed to fetch apikeys from cookies:${error?.message}`);
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(apiKeys)),
).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])),
)
).flat(),
...staticModels,

View File

@@ -1,5 +1,6 @@
import type { Message } from 'ai';
import { generateId, detectProjectType } from './fileUtils';
import { generateId } from './fileUtils';
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
export const createChatFromFolder = async (
files: File[],
@@ -8,17 +9,16 @@ export const createChatFromFolder = async (
): Promise<Message[]> => {
const fileArtifacts = await Promise.all(
files.map(async (file) => {
return new Promise<string>((resolve, reject) => {
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(
`<boltAction type="file" filePath="${relativePath}">
${content}
</boltAction>`,
);
resolve({
content,
path: relativePath,
});
};
reader.onerror = reject;
reader.readAsText(file);
@@ -26,38 +26,30 @@ ${content}
}),
);
const project = await detectProjectType(files);
const setupCommand = project.setupCommand
? `\n\n<boltAction type="shell">\n${project.setupCommand}\n</boltAction>`
: '';
const followupMessage = project.followupMessage ? `\n\n${project.followupMessage}` : '';
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 assistantMessages: Message[] = [
{
role: 'assistant',
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
const filesMessage: Message = {
role: 'assistant',
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
<boltArtifact id="imported-files" title="Imported Files">
${fileArtifacts.join('\n\n')}
${fileArtifacts
.map(
(file) => `<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
},
{
role: 'assistant',
content: `
<boltArtifact id="imported-files" title="Imported Files">
${setupCommand}
</boltArtifact>${followupMessage}`,
id: generateId(),
createdAt: new Date(),
},
];
id: generateId(),
createdAt: new Date(),
};
const userMessage: Message = {
role: 'user',
@@ -66,5 +58,11 @@ ${setupCommand}
createdAt: new Date(),
};
return [userMessage, ...assistantMessages];
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(),
};
}

49
app/utils/sampler.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Creates a function that samples calls at regular intervals and captures trailing calls.
* - Drops calls that occur between sampling intervals
* - Takes one call per sampling interval if available
* - Captures the last call if no call was made during the interval
*
* @param fn The function to sample
* @param sampleInterval How often to sample calls (in ms)
* @returns The sampled function
*/
export function createSampler<T extends (...args: any[]) => any>(fn: T, sampleInterval: number): T {
let lastArgs: Parameters<T> | null = null;
let lastTime = 0;
let timeout: NodeJS.Timeout | null = null;
// Create a function with the same type as the input function
const sampled = function (this: any, ...args: Parameters<T>) {
const now = Date.now();
lastArgs = args;
// If we're within the sample interval, just store the args
if (now - lastTime < sampleInterval) {
// Set up trailing call if not already set
if (!timeout) {
timeout = setTimeout(
() => {
timeout = null;
lastTime = Date.now();
if (lastArgs) {
fn.apply(this, lastArgs);
lastArgs = null;
}
},
sampleInterval - (now - lastTime),
);
}
return;
}
// If we're outside the interval, execute immediately
lastTime = now;
fn.apply(this, args);
lastArgs = null;
} as T;
return sampled;
}

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;
}