Merge
This commit is contained in:
@@ -1 +1 @@
|
||||
{ "commit": "5b6b26bc9ce287e6e351ca443ad0f411d1371a7f" }
|
||||
{ "commit": "fcb61ba49990d1d0cc13d05a0958007b6e9c1c38" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
33
app/components/chat/ScreenshotStateManager.tsx
Normal file
33
app/components/chat/ScreenshotStateManager.tsx
Normal 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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
117
app/components/git/GitUrlImport.client.tsx
Normal file
117
app/components/git/GitUrlImport.client.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
63
app/components/settings/Settings.module.scss
Normal file
63
app/components/settings/Settings.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/components/settings/SettingsWindow.tsx
Normal file
128
app/components/settings/SettingsWindow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
app/components/settings/chat-history/ChatHistoryTab.tsx
Normal file
113
app/components/settings/chat-history/ChatHistoryTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
app/components/settings/connections/ConnectionsTab.tsx
Normal file
54
app/components/settings/connections/ConnectionsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
494
app/components/settings/debug/DebugTab.tsx
Normal file
494
app/components/settings/debug/DebugTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
app/components/settings/event-logs/EventLogsTab.tsx
Normal file
219
app/components/settings/event-logs/EventLogsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
app/components/settings/features/FeaturesTab.tsx
Normal file
33
app/components/settings/features/FeaturesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
app/components/settings/providers/ProvidersTab.tsx
Normal file
95
app/components/settings/providers/ProvidersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
18
app/components/ui/BackgroundRays/index.tsx
Normal file
18
app/components/ui/BackgroundRays/index.tsx
Normal 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;
|
||||
246
app/components/ui/BackgroundRays/styles.module.scss
Normal file
246
app/components/ui/BackgroundRays/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
interface SettingsButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
37
app/components/ui/Switch.tsx
Normal file
37
app/components/ui/Switch.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
293
app/components/workbench/ScreenshotSelector.tsx
Normal file
293
app/components/workbench/ScreenshotSelector.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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" />
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 hasn’t 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
125
app/lib/hooks/useSettings.tsx
Normal file
125
app/lib/hooks/useSettings.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
149
app/lib/stores/logs.ts
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
app/root.tsx
19
app/root.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
23
app/routes/git.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
8
app/types/global.d.ts
vendored
8
app/types/global.d.ts
vendored
@@ -3,3 +3,11 @@ interface Window {
|
||||
webkitSpeechRecognition: typeof SpeechRecognition;
|
||||
SpeechRecognition: typeof SpeechRecognition;
|
||||
}
|
||||
|
||||
interface Performance {
|
||||
memory?: {
|
||||
jsHeapSizeLimit: number;
|
||||
totalJSHeapSize: number;
|
||||
usedJSHeapSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
80
app/utils/projectCommands.ts
Normal file
80
app/utils/projectCommands.ts
Normal 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
49
app/utils/sampler.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user