merged main
This commit is contained in:
1
app/commit.json
Normal file
1
app/commit.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "commit": "1e04ab38b07e82852626b164890f4a6df1f98cef" }
|
||||
@@ -28,6 +28,7 @@ interface ArtifactProps {
|
||||
export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
const userToggledActions = useRef(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [allActionFinished, setAllActionFinished] = useState(false);
|
||||
|
||||
const artifacts = useStore(workbenchStore.artifacts);
|
||||
const artifact = artifacts[messageId];
|
||||
@@ -47,6 +48,14 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
if (actions.length && !showActions && !userToggledActions.current) {
|
||||
setShowActions(true);
|
||||
}
|
||||
|
||||
if (actions.length !== 0 && artifact.type === 'bundled') {
|
||||
const finished = !actions.find((action) => action.status !== 'complete');
|
||||
|
||||
if (allActionFinished !== finished) {
|
||||
setAllActionFinished(finished);
|
||||
}
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
return (
|
||||
@@ -59,6 +68,18 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
workbenchStore.showWorkbench.set(!showWorkbench);
|
||||
}}
|
||||
>
|
||||
{artifact.type == 'bundled' && (
|
||||
<>
|
||||
<div className="p-4">
|
||||
{allActionFinished ? (
|
||||
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
|
||||
) : (
|
||||
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
</>
|
||||
)}
|
||||
<div className="px-5 p-3.5 w-full text-left">
|
||||
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
|
||||
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
|
||||
@@ -66,7 +87,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
</button>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
<AnimatePresence>
|
||||
{actions.length && (
|
||||
{actions.length && artifact.type !== 'bundled' && (
|
||||
<motion.button
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: 'auto' }}
|
||||
@@ -83,7 +104,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showActions && actions.length > 0 && (
|
||||
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
|
||||
<motion.div
|
||||
className="actions"
|
||||
initial={{ height: 0 }}
|
||||
@@ -92,6 +113,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
||||
|
||||
<div className="p-5 text-left bg-bolt-elements-actions-background">
|
||||
<ActionList actions={actions} />
|
||||
</div>
|
||||
|
||||
@@ -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,14 +17,15 @@ import Cookies from 'js-cookie';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
import styles from './BaseChat.module.scss';
|
||||
import type { ProviderInfo } from '~/utils/types';
|
||||
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
|
||||
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
import GitCloneButton from './GitCloneButton';
|
||||
|
||||
import FilePreview from './FilePreview';
|
||||
import { ModelSelector } from '~/components/chat/ModelSelector';
|
||||
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||
import type { IProviderSetting, ProviderInfo } from '~/types/model';
|
||||
import { ScreenshotStateManager } from './ScreenshotStateManager';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
@@ -45,6 +46,7 @@ interface BaseChatProps {
|
||||
setModel?: (model: string) => void;
|
||||
provider?: ProviderInfo;
|
||||
setProvider?: (provider: ProviderInfo) => void;
|
||||
providerList?: ProviderInfo[];
|
||||
handleStop?: () => void;
|
||||
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
@@ -70,6 +72,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
setModel,
|
||||
provider,
|
||||
setProvider,
|
||||
providerList,
|
||||
input = '',
|
||||
enhancingPrompt,
|
||||
handleInputChange,
|
||||
@@ -88,14 +91,30 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
ref,
|
||||
) => {
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
|
||||
const savedKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (savedKeys) {
|
||||
try {
|
||||
return JSON.parse(savedKeys);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse API keys from cookies:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
const [modelList, setModelList] = useState(MODEL_LIST);
|
||||
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
|
||||
console.log(transcript);
|
||||
useEffect(() => {
|
||||
console.log(transcript);
|
||||
}, [transcript]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load API keys from cookies on component mount
|
||||
try {
|
||||
@@ -115,7 +134,26 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
Cookies.remove('apiKeys');
|
||||
}
|
||||
|
||||
initializeModelList().then((modelList) => {
|
||||
let providerSettings: Record<string, IProviderSetting> | undefined = undefined;
|
||||
|
||||
try {
|
||||
const savedProviderSettings = Cookies.get('providers');
|
||||
|
||||
if (savedProviderSettings) {
|
||||
const parsedProviderSettings = JSON.parse(savedProviderSettings);
|
||||
|
||||
if (typeof parsedProviderSettings === 'object' && parsedProviderSettings !== null) {
|
||||
providerSettings = parsedProviderSettings;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading Provider Settings from cookies:', error);
|
||||
|
||||
// Clear invalid cookie data
|
||||
Cookies.remove('providers');
|
||||
}
|
||||
|
||||
initializeModelList(providerSettings).then((modelList) => {
|
||||
setModelList(modelList);
|
||||
});
|
||||
|
||||
@@ -184,23 +222,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const updateApiKey = (provider: string, key: string) => {
|
||||
try {
|
||||
const updatedApiKeys = { ...apiKeys, [provider]: key };
|
||||
setApiKeys(updatedApiKeys);
|
||||
|
||||
// Save updated API keys to cookies with 30 day expiry and secure settings
|
||||
Cookies.set('apiKeys', JSON.stringify(updatedApiKeys), {
|
||||
expires: 30, // 30 days
|
||||
secure: true, // Only send over HTTPS
|
||||
sameSite: 'strict', // Protect against CSRF
|
||||
path: '/', // Accessible across the site
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving API keys to cookies:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
@@ -256,24 +277,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
const baseChat = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.BaseChat,
|
||||
'relative flex flex-col lg:flex-row h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
|
||||
)}
|
||||
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<div className={classNames(styles.RayContainer)}>
|
||||
<div className={classNames(styles.LightRayOne)}></div>
|
||||
<div className={classNames(styles.LightRayTwo)}></div>
|
||||
<div className={classNames(styles.LightRayThree)}></div>
|
||||
<div className={classNames(styles.LightRayFour)}></div>
|
||||
<div className={classNames(styles.LightRayFive)}></div>
|
||||
</div>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[26vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
Where ideas begin
|
||||
</h1>
|
||||
@@ -318,15 +329,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(-45)"
|
||||
>
|
||||
<stop offset="0%" stopColor="#1488fc" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#1488fc" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#1488fc" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#1488fc" stopOpacity="0%"></stop>
|
||||
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine-gradient">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#8adaff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#8adaff" stopOpacity="80%"></stop>
|
||||
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
@@ -334,21 +345,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<button
|
||||
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
|
||||
className={classNames('flex items-center gap-2 p-2 rounded-lg transition-all', {
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
|
||||
isModelSettingsCollapsed,
|
||||
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
|
||||
!isModelSettingsCollapsed,
|
||||
})}
|
||||
>
|
||||
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
|
||||
<span>Model Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
|
||||
<ModelSelector
|
||||
key={provider?.name + ':' + modelList.length}
|
||||
@@ -357,14 +353,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
modelList={modelList}
|
||||
provider={provider}
|
||||
setProvider={setProvider}
|
||||
providerList={PROVIDER_LIST}
|
||||
providerList={providerList || PROVIDER_LIST}
|
||||
apiKeys={apiKeys}
|
||||
/>
|
||||
{provider && (
|
||||
{(providerList || []).length > 0 && provider && (
|
||||
<APIKeyManager
|
||||
provider={provider}
|
||||
apiKey={apiKeys[provider.name] || ''}
|
||||
setApiKey={(key) => updateApiKey(provider.name, key)}
|
||||
setApiKey={(key) => {
|
||||
const newApiKeys = { ...apiKeys, [provider.name]: key };
|
||||
setApiKeys(newApiKeys);
|
||||
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -395,7 +395,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={classNames(
|
||||
'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
||||
'w-full pl-4 pt-4 pr-16 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
||||
'transition-all duration-200',
|
||||
'hover:border-bolt-elements-focus',
|
||||
)}
|
||||
@@ -462,6 +462,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<SendButton
|
||||
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!providerList || providerList.length === 0}
|
||||
onClick={(event) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
@@ -512,6 +513,20 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
|
||||
<IconButton
|
||||
title="Model Settings"
|
||||
className={classNames('transition-all flex items-center gap-1', {
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
|
||||
isModelSettingsCollapsed,
|
||||
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
|
||||
!isModelSettingsCollapsed,
|
||||
})}
|
||||
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
|
||||
disabled={!providerList || providerList.length === 0}
|
||||
>
|
||||
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
|
||||
{isModelSettingsCollapsed ? <span className="text-xs">{model}</span> : <span />}
|
||||
</IconButton>
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
@@ -524,7 +539,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!chatStarted && ImportButtons(importChat)}
|
||||
{!chatStarted && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{ImportButtons(importChat)}
|
||||
<GitCloneButton importChat={importChat} />
|
||||
</div>
|
||||
)}
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
|
||||
@@ -17,8 +17,9 @@ import { cubicEasingFn } from '~/utils/easings';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import { BaseChat } from './BaseChat';
|
||||
import Cookies from 'js-cookie';
|
||||
import type { ProviderInfo } from '~/utils/types';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -91,6 +92,7 @@ export const ChatImpl = memo(
|
||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||
const { activeProviders } = useSettings();
|
||||
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
@@ -316,6 +318,7 @@ export const ChatImpl = memo(
|
||||
setModel={handleModelChange}
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
providerList={activeProviders}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
|
||||
110
app/components/chat/GitCloneButton.tsx
Normal file
110
app/components/chat/GitCloneButton.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import ignore from 'ignore';
|
||||
import { useGit } from '~/lib/hooks/useGit';
|
||||
import type { Message } from 'ai';
|
||||
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
|
||||
import { generateId } from '~/utils/fileUtils';
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'.github/**',
|
||||
'.vscode/**',
|
||||
'**/*.jpg',
|
||||
'**/*.jpeg',
|
||||
'**/*.png',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
'**/*lock.json',
|
||||
'**/*lock.yaml',
|
||||
];
|
||||
|
||||
const ig = ignore().add(IGNORE_PATTERNS);
|
||||
|
||||
interface GitCloneButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
|
||||
const { ready, gitClone } = useGit();
|
||||
const onClick = async (_e: any) => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repoUrl = prompt('Enter the Git url');
|
||||
|
||||
if (repoUrl) {
|
||||
const { workdir, data } = await gitClone(repoUrl);
|
||||
|
||||
if (importChat) {
|
||||
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
|
||||
console.log(filePaths);
|
||||
|
||||
const textDecoder = new TextDecoder('utf-8');
|
||||
|
||||
// Convert files to common format for command detection
|
||||
const fileContents = filePaths
|
||||
.map((filePath) => {
|
||||
const { data: content, encoding } = data[filePath];
|
||||
return {
|
||||
path: filePath,
|
||||
content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '',
|
||||
};
|
||||
})
|
||||
.filter((f) => f.content);
|
||||
|
||||
// Detect and create commands message
|
||||
const commands = await detectProjectCommands(fileContents);
|
||||
const commandsMessage = createCommandsMessage(commands);
|
||||
|
||||
// Create files message
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Cloning the repo ${repoUrl} into ${workdir}
|
||||
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
|
||||
${fileContents
|
||||
.map(
|
||||
(file) =>
|
||||
`<boltAction type="file" filePath="${file.path}">
|
||||
${file.content}
|
||||
</boltAction>`,
|
||||
)
|
||||
.join('\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const messages = [filesMessage];
|
||||
|
||||
if (commandsMessage) {
|
||||
messages.push(commandsMessage);
|
||||
}
|
||||
|
||||
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title="Clone a Git Repo"
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="i-ph:git-branch" />
|
||||
Clone a Git Repo
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,98 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import ignore from 'ignore';
|
||||
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
|
||||
import { createChatFromFolder } from '~/utils/folderImport';
|
||||
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
||||
|
||||
interface ImportFolderButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// Common patterns to ignore, similar to .gitignore
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
];
|
||||
|
||||
const ig = ignore().add(IGNORE_PATTERNS);
|
||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||
const chunkSize = 1024; // Read the first 1 KB of the file
|
||||
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
|
||||
return true; // Found a binary character
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
||||
const shouldIncludeFile = (path: string): boolean => {
|
||||
return !ig.ignores(path);
|
||||
};
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
|
||||
const fileArtifacts = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const allFiles = Array.from(e.target.files || []);
|
||||
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string;
|
||||
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
||||
resolve(
|
||||
`<boltAction type="file" filePath="${relativePath}">
|
||||
${content}
|
||||
</boltAction>`,
|
||||
);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
if (allFiles.length > MAX_FILES) {
|
||||
const error = new Error(`Too many files: ${allFiles.length}`);
|
||||
logStore.logError('File import failed - too many files', error, {
|
||||
fileCount: allFiles.length,
|
||||
maxFiles: MAX_FILES,
|
||||
});
|
||||
toast.error(
|
||||
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
|
||||
setIsLoading(true);
|
||||
|
||||
const loadingToast = toast.loading(`Importing ${folderName}...`);
|
||||
|
||||
try {
|
||||
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||
|
||||
if (filteredFiles.length === 0) {
|
||||
const error = new Error('No valid files found');
|
||||
logStore.logError('File import failed - no valid files', error, { folderName });
|
||||
toast.error('No files found in the selected folder');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fileChecks = await Promise.all(
|
||||
filteredFiles.map(async (file) => ({
|
||||
file,
|
||||
isBinary: await isBinaryFile(file),
|
||||
})),
|
||||
);
|
||||
|
||||
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
||||
const binaryFilePaths = fileChecks
|
||||
.filter((f) => f.isBinary)
|
||||
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
const error = new Error('No text files found');
|
||||
logStore.logError('File import failed - no text files', error, { folderName });
|
||||
toast.error('No text files found in the selected folder');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (binaryFilePaths.length > 0) {
|
||||
logStore.logWarning(`Skipping binary files during import`, {
|
||||
folderName,
|
||||
binaryCount: binaryFilePaths.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
const binaryFilesMessage =
|
||||
binaryFiles.length > 0
|
||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
|
||||
|
||||
const message: Message = {
|
||||
role: 'assistant',
|
||||
content: `I'll help you set up these files.${binaryFilesMessage}
|
||||
if (importChat) {
|
||||
await importChat(folderName, [...messages]);
|
||||
}
|
||||
|
||||
<boltArtifact id="imported-files" title="Imported Files">
|
||||
${fileArtifacts.join('\n\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
id: generateId(),
|
||||
content: 'Import my files',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
|
||||
|
||||
if (importChat) {
|
||||
await importChat(description, [userMessage, message]);
|
||||
logStore.logSystem('Folder imported successfully', {
|
||||
folderName,
|
||||
textFileCount: textFiles.length,
|
||||
binaryFileCount: binaryFilePaths.length,
|
||||
});
|
||||
toast.success('Folder imported successfully');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to import folder', error, { folderName });
|
||||
console.error('Failed to import folder:', error);
|
||||
toast.error('Failed to import folder');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
toast.dismiss(loadingToast);
|
||||
e.target.value = ''; // Reset file input
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,46 +104,8 @@ ${fileArtifacts.join('\n\n')}
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={async (e) => {
|
||||
const allFiles = Array.from(e.target.files || []);
|
||||
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||
|
||||
if (filteredFiles.length === 0) {
|
||||
toast.error('No files found in the selected folder');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileChecks = await Promise.all(
|
||||
filteredFiles.map(async (file) => ({
|
||||
file,
|
||||
isBinary: await isBinaryFile(file),
|
||||
})),
|
||||
);
|
||||
|
||||
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
||||
const binaryFilePaths = fileChecks
|
||||
.filter((f) => f.isBinary)
|
||||
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
toast.error('No text files found in the selected folder');
|
||||
return;
|
||||
}
|
||||
|
||||
if (binaryFilePaths.length > 0) {
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
await createChatFromFolder(textFiles, binaryFilePaths);
|
||||
} catch (error) {
|
||||
console.error('Failed to import folder:', error);
|
||||
toast.error('Failed to import folder');
|
||||
}
|
||||
|
||||
e.target.value = ''; // Reset file input
|
||||
}}
|
||||
{...({} as any)} // if removed webkitdirectory will throw errors as unknow attribute
|
||||
onChange={handleFileChange}
|
||||
{...({} as any)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -155,9 +113,10 @@ ${fileArtifacts.join('\n\n')}
|
||||
input?.click();
|
||||
}}
|
||||
className={className}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="i-ph:upload-simple" />
|
||||
Import Folder
|
||||
{isLoading ? 'Importing...' : 'Import Folder'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import type { ModelInfo } from '~/utils/types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
@@ -19,6 +20,39 @@ export const ModelSelector = ({
|
||||
modelList,
|
||||
providerList,
|
||||
}: ModelSelectorProps) => {
|
||||
// Load enabled providers from cookies
|
||||
|
||||
// Update enabled providers when cookies change
|
||||
useEffect(() => {
|
||||
// If current provider is disabled, switch to first enabled provider
|
||||
if (providerList.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
|
||||
const firstEnabledProvider = providerList[0];
|
||||
setProvider?.(firstEnabledProvider);
|
||||
|
||||
// Also update the model to the first available one for the new provider
|
||||
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
||||
|
||||
if (firstModel) {
|
||||
setModel?.(firstModel.name);
|
||||
}
|
||||
}
|
||||
}, [providerList, provider, setProvider, modelList, setModel]);
|
||||
|
||||
if (providerList.length === 0) {
|
||||
return (
|
||||
<div className="mb-2 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary">
|
||||
<p className="text-center">
|
||||
No providers are currently enabled. Please enable at least one provider in the settings to start using the
|
||||
chat.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
@@ -52,8 +86,8 @@ export const ModelSelector = ({
|
||||
>
|
||||
{[...modelList]
|
||||
.filter((e) => e.provider == provider?.name && e.name)
|
||||
.map((modelOption) => (
|
||||
<option key={modelOption.name} value={modelOption.name}>
|
||||
.map((modelOption, index) => (
|
||||
<option key={index} value={modelOption.name}>
|
||||
{modelOption.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -3,25 +3,30 @@ import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
|
||||
interface SendButtonProps {
|
||||
show: boolean;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onImagesSelected?: (images: File[]) => void;
|
||||
}
|
||||
|
||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
|
||||
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
|
||||
export const SendButton = ({ show, isStreaming, disabled, onClick }: SendButtonProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
<motion.button
|
||||
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme"
|
||||
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
transition={{ ease: customEasingFn, duration: 0.17 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClick?.(event);
|
||||
|
||||
if (!disabled) {
|
||||
onClick?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-lg">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import React from 'react';
|
||||
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
|
||||
|
||||
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<div className="flex flex-col items-center justify-center w-auto">
|
||||
<input
|
||||
type="file"
|
||||
id="chat-import"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
app/components/settings/connections/ConnectionsTab.tsx
Normal file
53
app/components/settings/connections/ConnectionsTab.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Cookies from 'js-cookie';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
export default function ConnectionsTab() {
|
||||
const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
|
||||
const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || '');
|
||||
|
||||
const handleSaveConnection = () => {
|
||||
Cookies.set('githubUsername', githubUsername);
|
||||
Cookies.set('githubToken', githubToken);
|
||||
logStore.logSystem('GitHub connection settings updated', {
|
||||
username: githubUsername,
|
||||
hasToken: !!githubToken,
|
||||
});
|
||||
toast.success('GitHub credentials saved successfully!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 mb-4 border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">GitHub Connection</h3>
|
||||
<div className="flex mb-4">
|
||||
<div className="flex-1 mr-2">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">GitHub Username:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={githubUsername}
|
||||
onChange={(e) => setGithubUsername(e.target.value)}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Personal Access Token:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
<button
|
||||
onClick={handleSaveConnection}
|
||||
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 mr-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
|
||||
>
|
||||
Save Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,6 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
||||
import { SettingsWindow } from '~/components/settings/SettingsWindow';
|
||||
import { SettingsButton } from '~/components/ui/SettingsButton';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { logger } from '~/utils/logger';
|
||||
@@ -33,12 +35,32 @@ const menuVariants = {
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
|
||||
function CurrentDateTime() {
|
||||
const [dateTime, setDateTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setDateTime(new Date());
|
||||
}, 60000); // Update every minute
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor">
|
||||
<div className="h-4 w-4 i-ph:clock-thin" />
|
||||
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Menu = () => {
|
||||
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
|
||||
items: list,
|
||||
@@ -123,18 +145,17 @@ export const Menu = () => {
|
||||
variants={menuVariants}
|
||||
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
||||
>
|
||||
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
|
||||
<div className="h-[60px]" /> {/* Spacer for top margin */}
|
||||
<CurrentDateTime />
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4 select-none">
|
||||
<a
|
||||
href="/"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4"
|
||||
>
|
||||
<span className="inline-block i-bolt:chat scale-110" />
|
||||
Start new chat
|
||||
</a>
|
||||
</div>
|
||||
<div className="pl-4 pr-4 my-2">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
@@ -200,10 +221,12 @@ export const Menu = () => {
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
|
||||
<ThemeSwitch className="ml-auto" />
|
||||
<div className="flex items-center justify-between border-t border-bolt-elements-borderColor p-4">
|
||||
<SettingsButton onClick={() => setIsSettingsOpen(true)} />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<SettingsWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
17
app/components/ui/SettingsButton.tsx
Normal file
17
app/components/ui/SettingsButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
interface SettingsButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const SettingsButton = memo(({ onClick }: SettingsButtonProps) => {
|
||||
return (
|
||||
<IconButton
|
||||
onClick={onClick}
|
||||
icon="i-ph:gear"
|
||||
size="xl"
|
||||
title="Settings"
|
||||
className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors"
|
||||
/>
|
||||
);
|
||||
});
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -5,11 +5,16 @@ import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PortDropdown } from './PortDropdown';
|
||||
import { ScreenshotSelector } from './ScreenshotSelector';
|
||||
|
||||
type ResizeSide = 'left' | 'right' | null;
|
||||
|
||||
export const Preview = memo(() => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const hasSelectedPreview = useRef(false);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
@@ -18,6 +23,23 @@ export const Preview = memo(() => {
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
// Toggle between responsive mode and device mode
|
||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||
|
||||
// Use percentage for width
|
||||
const [widthPercent, setWidthPercent] = useState<number>(37.5); // 375px assuming 1000px window width initially
|
||||
|
||||
const resizingState = useRef({
|
||||
isResizing: false,
|
||||
side: null as ResizeSide,
|
||||
startX: 0,
|
||||
startWidthPercent: 37.5,
|
||||
windowWidth: window.innerWidth,
|
||||
});
|
||||
|
||||
// Define the scaling factor
|
||||
const SCALING_FACTOR = 2; // Adjust this value to increase/decrease sensitivity
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreview) {
|
||||
setUrl('');
|
||||
@@ -27,10 +49,9 @@ export const Preview = memo(() => {
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
|
||||
setUrl(baseUrl);
|
||||
setIframeUrl(baseUrl);
|
||||
}, [activePreview, iframeUrl]);
|
||||
}, [activePreview]);
|
||||
|
||||
const validateUrl = useCallback(
|
||||
(value: string) => {
|
||||
@@ -58,14 +79,13 @@ export const Preview = memo(() => {
|
||||
[],
|
||||
);
|
||||
|
||||
// when previews change, display the lowest port if user hasn't selected a preview
|
||||
// When previews change, display the lowest port if user hasn't selected a preview
|
||||
useEffect(() => {
|
||||
if (previews.length > 1 && !hasSelectedPreview.current) {
|
||||
const minPortIndex = previews.reduce(findMinPortIndex, 0);
|
||||
|
||||
setActivePreviewIndex(minPortIndex);
|
||||
}
|
||||
}, [previews]);
|
||||
}, [previews, findMinPortIndex]);
|
||||
|
||||
const reloadPreview = () => {
|
||||
if (iframeRef.current) {
|
||||
@@ -73,8 +93,128 @@ export const Preview = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!isFullscreen && containerRef.current) {
|
||||
await containerRef.current.requestFullscreen();
|
||||
} else if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDeviceMode = () => {
|
||||
setIsDeviceModeOn((prev) => !prev);
|
||||
};
|
||||
|
||||
const startResizing = (e: React.MouseEvent, side: ResizeSide) => {
|
||||
if (!isDeviceModeOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent text selection
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
resizingState.current.isResizing = true;
|
||||
resizingState.current.side = side;
|
||||
resizingState.current.startX = e.clientX;
|
||||
resizingState.current.startWidthPercent = widthPercent;
|
||||
resizingState.current.windowWidth = window.innerWidth;
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
e.preventDefault(); // Prevent any text selection on mousedown
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!resizingState.current.isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = e.clientX - resizingState.current.startX;
|
||||
const windowWidth = resizingState.current.windowWidth;
|
||||
|
||||
// Apply scaling factor to increase sensitivity
|
||||
const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR;
|
||||
|
||||
let newWidthPercent = resizingState.current.startWidthPercent;
|
||||
|
||||
if (resizingState.current.side === 'right') {
|
||||
newWidthPercent = resizingState.current.startWidthPercent + dxPercent;
|
||||
} else if (resizingState.current.side === 'left') {
|
||||
newWidthPercent = resizingState.current.startWidthPercent - dxPercent;
|
||||
}
|
||||
|
||||
// Clamp the width between 10% and 90%
|
||||
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
|
||||
|
||||
setWidthPercent(newWidthPercent);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
resizingState.current.isResizing = false;
|
||||
resizingState.current.side = null;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Restore text selection
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
// Handle window resize to ensure widthPercent remains valid
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
/*
|
||||
* Optional: Adjust widthPercent if necessary
|
||||
* For now, since widthPercent is relative, no action is needed
|
||||
*/
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A small helper component for the handle's "grip" icon
|
||||
const GripIcon = () => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: 'rgba(0,0,0,0.5)',
|
||||
fontSize: '10px',
|
||||
lineHeight: '5px',
|
||||
userSelect: 'none',
|
||||
marginLeft: '1px',
|
||||
}}
|
||||
>
|
||||
••• •••
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div ref={containerRef} className="w-full h-full flex flex-col relative">
|
||||
{isPortDropdownOpen && (
|
||||
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
|
||||
)}
|
||||
@@ -109,6 +249,7 @@ export const Preview = memo(() => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{previews.length > 1 && (
|
||||
<PortDropdown
|
||||
activePreviewIndex={activePreviewIndex}
|
||||
@@ -119,20 +260,100 @@ export const Preview = memo(() => {
|
||||
previews={previews}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Device mode toggle button */}
|
||||
<IconButton
|
||||
icon="i-ph:devices"
|
||||
onClick={toggleDeviceMode}
|
||||
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
|
||||
/>
|
||||
|
||||
{/* Fullscreen toggle button */}
|
||||
<IconButton
|
||||
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
||||
{activePreview ? (
|
||||
<>
|
||||
<iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} />
|
||||
<ScreenshotSelector
|
||||
|
||||
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
|
||||
<div
|
||||
style={{
|
||||
width: isDeviceModeOn ? `${widthPercent}%` : '100%',
|
||||
height: '100%', // Always full height
|
||||
overflow: 'visible',
|
||||
background: '#fff',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{activePreview ? (
|
||||
<>
|
||||
<iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
|
||||
<ScreenshotSelector
|
||||
isSelectionMode={isSelectionMode}
|
||||
setIsSelectionMode={setIsSelectionMode}
|
||||
containerRef={iframeRef}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
)}
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
)}
|
||||
|
||||
{isDeviceModeOn && (
|
||||
<>
|
||||
{/* Left handle */}
|
||||
<div
|
||||
onMouseDown={(e) => startResizing(e, 'left')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '15px',
|
||||
marginLeft: '-15px',
|
||||
height: '100%',
|
||||
cursor: 'ew-resize',
|
||||
background: 'rgba(255,255,255,.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
|
||||
title="Drag to resize width"
|
||||
>
|
||||
<GripIcon />
|
||||
</div>
|
||||
|
||||
{/* Right handle */}
|
||||
<div
|
||||
onMouseDown={(e) => startResizing(e, 'right')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '15px',
|
||||
marginRight: '-15px',
|
||||
height: '100%',
|
||||
cursor: 'ew-resize',
|
||||
background: 'rgba(255,255,255,.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
|
||||
title="Drag to resize width"
|
||||
>
|
||||
<GripIcon />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -117,7 +117,7 @@ export const ScreenshotSelector = memo(
|
||||
|
||||
// Get window scroll position
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
const scrollY = window.scrollY + 40;
|
||||
|
||||
// Get the container's position in the page
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -51,7 +51,7 @@ export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Re
|
||||
export function getBaseURL(cloudflareEnv: Env, provider: string) {
|
||||
switch (provider) {
|
||||
case 'Together':
|
||||
return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL;
|
||||
return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL || 'https://api.together.xyz/v1';
|
||||
case 'OpenAILike':
|
||||
return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
|
||||
case 'LMStudio':
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck – TODO: Provider proper types
|
||||
|
||||
import { convertToCoreMessages, streamText as _streamText } from 'ai';
|
||||
import { getModel } from '~/lib/.server/llm/model';
|
||||
import { MAX_TOKENS } from './constants';
|
||||
import { getSystemPrompt } from './prompts';
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_LIST, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
interface ToolResult<Name extends string, Args, Result> {
|
||||
toolCallId: string;
|
||||
@@ -43,7 +41,7 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid
|
||||
* Extract provider
|
||||
* const providerMatch = message.content.match(PROVIDER_REGEX);
|
||||
*/
|
||||
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
|
||||
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
|
||||
|
||||
const cleanedContent = Array.isArray(message.content)
|
||||
? message.content.map((item) => {
|
||||
@@ -61,10 +59,17 @@ function extractPropertiesFromMessage(message: Message): { model: string; provid
|
||||
return { model, provider, content: cleanedContent };
|
||||
}
|
||||
|
||||
export function streamText(messages: Messages, env: Env, options?: StreamingOptions, apiKeys?: Record<string, string>) {
|
||||
export async function streamText(props: {
|
||||
messages: Messages;
|
||||
env: Env;
|
||||
options?: StreamingOptions;
|
||||
apiKeys?: Record<string, string>;
|
||||
providerSettings?: Record<string, IProviderSetting>;
|
||||
}) {
|
||||
const { messages, env, options, apiKeys, providerSettings } = props;
|
||||
let currentModel = DEFAULT_MODEL;
|
||||
let currentProvider = DEFAULT_PROVIDER;
|
||||
|
||||
let currentProvider = DEFAULT_PROVIDER.name;
|
||||
const MODEL_LIST = await getModelList(apiKeys || {}, providerSettings);
|
||||
const processedMessages = messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
const { model, provider, content } = extractPropertiesFromMessage(message);
|
||||
@@ -86,10 +91,10 @@ export function streamText(messages: Messages, env: Env, options?: StreamingOpti
|
||||
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
|
||||
|
||||
return _streamText({
|
||||
...options,
|
||||
model: getModel(currentProvider, currentModel, env, apiKeys),
|
||||
model: getModel(currentProvider, currentModel, env, apiKeys, providerSettings) as any,
|
||||
system: getSystemPrompt(),
|
||||
maxTokens: dynamicMaxTokens,
|
||||
messages: convertToCoreMessages(processedMessages),
|
||||
messages: convertToCoreMessages(processedMessages as any),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
287
app/lib/hooks/useGit.ts
Normal file
287
app/lib/hooks/useGit.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { WebContainer } from '@webcontainer/api';
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
||||
import { webcontainer as webcontainerPromise } from '~/lib/webcontainer';
|
||||
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
|
||||
import http from 'isomorphic-git/http/web';
|
||||
import Cookies from 'js-cookie';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const lookupSavedPassword = (url: string) => {
|
||||
const domain = url.split('/')[2];
|
||||
const gitCreds = Cookies.get(`git:${domain}`);
|
||||
|
||||
if (!gitCreds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { username, password } = JSON.parse(gitCreds || '{}');
|
||||
return { username, password };
|
||||
} catch (error) {
|
||||
console.log(`Failed to parse Git Cookie ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitAuth = (url: string, auth: GitAuth) => {
|
||||
const domain = url.split('/')[2];
|
||||
Cookies.set(`git:${domain}`, JSON.stringify(auth));
|
||||
};
|
||||
|
||||
export function useGit() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [webcontainer, setWebcontainer] = useState<WebContainer>();
|
||||
const [fs, setFs] = useState<PromiseFsClient>();
|
||||
const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
|
||||
useEffect(() => {
|
||||
webcontainerPromise.then((container) => {
|
||||
fileData.current = {};
|
||||
setWebcontainer(container);
|
||||
setFs(getFs(container, fileData));
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const gitClone = useCallback(
|
||||
async (url: string) => {
|
||||
if (!webcontainer || !fs || !ready) {
|
||||
throw 'Webcontainer not initialized';
|
||||
}
|
||||
|
||||
fileData.current = {};
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir: webcontainer.workdir,
|
||||
url,
|
||||
depth: 1,
|
||||
singleBranch: true,
|
||||
corsProxy: 'https://cors.isomorphic-git.org',
|
||||
onAuth: (url) => {
|
||||
// let domain=url.split("/")[2]
|
||||
|
||||
let auth = lookupSavedPassword(url);
|
||||
|
||||
if (auth) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
|
||||
auth = {
|
||||
username: prompt('Enter username'),
|
||||
password: prompt('Enter password'),
|
||||
};
|
||||
return auth;
|
||||
} else {
|
||||
return { cancel: true };
|
||||
}
|
||||
},
|
||||
onAuthFailure: (url, _auth) => {
|
||||
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
|
||||
},
|
||||
onAuthSuccess: (url, auth) => {
|
||||
saveGitAuth(url, auth);
|
||||
},
|
||||
});
|
||||
|
||||
const data: Record<string, { data: any; encoding?: string }> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(fileData.current)) {
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
return { workdir: webcontainer.workdir, data };
|
||||
},
|
||||
[webcontainer],
|
||||
);
|
||||
|
||||
return { ready, gitClone };
|
||||
}
|
||||
|
||||
const getFs = (
|
||||
webcontainer: WebContainer,
|
||||
record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
|
||||
) => ({
|
||||
promises: {
|
||||
readFile: async (path: string, options: any) => {
|
||||
const encoding = options.encoding;
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
console.log('readFile', relativePath, encoding);
|
||||
|
||||
return await webcontainer.fs.readFile(relativePath, encoding);
|
||||
},
|
||||
writeFile: async (path: string, data: any, options: any) => {
|
||||
const encoding = options.encoding;
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
console.log('writeFile', { relativePath, data, encoding });
|
||||
|
||||
if (record.current) {
|
||||
record.current[relativePath] = { data, encoding };
|
||||
}
|
||||
|
||||
return await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
|
||||
},
|
||||
mkdir: async (path: string, options: any) => {
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
console.log('mkdir', relativePath, options);
|
||||
|
||||
return await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true });
|
||||
},
|
||||
readdir: async (path: string, options: any) => {
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
console.log('readdir', relativePath, options);
|
||||
|
||||
return await webcontainer.fs.readdir(relativePath, options);
|
||||
},
|
||||
rm: async (path: string, options: any) => {
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
console.log('rm', relativePath, options);
|
||||
|
||||
return await webcontainer.fs.rm(relativePath, { ...(options || {}) });
|
||||
},
|
||||
rmdir: async (path: string, options: any) => {
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
console.log('rmdir', relativePath, options);
|
||||
|
||||
return await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
|
||||
},
|
||||
|
||||
// Mock implementations for missing functions
|
||||
unlink: async (path: string) => {
|
||||
// unlink is just removing a single file
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
return await webcontainer.fs.rm(relativePath, { recursive: false });
|
||||
},
|
||||
|
||||
stat: async (path: string) => {
|
||||
try {
|
||||
const relativePath = pathUtils.relative(webcontainer.workdir, path);
|
||||
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
|
||||
const name = pathUtils.basename(relativePath);
|
||||
const fileInfo = resp.find((x) => x.name == name);
|
||||
|
||||
if (!fileInfo) {
|
||||
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
isFile: () => fileInfo.isFile(),
|
||||
isDirectory: () => fileInfo.isDirectory(),
|
||||
isSymbolicLink: () => false,
|
||||
size: 1,
|
||||
mode: 0o666, // Default permissions
|
||||
mtimeMs: Date.now(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.log(error?.message);
|
||||
|
||||
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
err.syscall = 'stat';
|
||||
err.path = path;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
lstat: async (path: string) => {
|
||||
/*
|
||||
* For basic usage, lstat can return the same as stat
|
||||
* since we're not handling symbolic links
|
||||
*/
|
||||
return await getFs(webcontainer, record).promises.stat(path);
|
||||
},
|
||||
|
||||
readlink: async (path: string) => {
|
||||
/*
|
||||
* Since WebContainer doesn't support symlinks,
|
||||
* we'll throw a "not a symbolic link" error
|
||||
*/
|
||||
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
|
||||
},
|
||||
|
||||
symlink: async (target: string, path: string) => {
|
||||
/*
|
||||
* Since WebContainer doesn't support symlinks,
|
||||
* we'll throw a "operation not supported" error
|
||||
*/
|
||||
throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`);
|
||||
},
|
||||
|
||||
chmod: async (_path: string, _mode: number) => {
|
||||
/*
|
||||
* WebContainer doesn't support changing permissions,
|
||||
* but we can pretend it succeeded for compatibility
|
||||
*/
|
||||
return await Promise.resolve();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pathUtils = {
|
||||
dirname: (path: string) => {
|
||||
// Handle empty or just filename cases
|
||||
if (!path || !path.includes('/')) {
|
||||
return '.';
|
||||
}
|
||||
|
||||
// Remove trailing slashes
|
||||
path = path.replace(/\/+$/, '');
|
||||
|
||||
// Get directory part
|
||||
return path.split('/').slice(0, -1).join('/') || '/';
|
||||
},
|
||||
|
||||
basename: (path: string, ext?: string) => {
|
||||
// Remove trailing slashes
|
||||
path = path.replace(/\/+$/, '');
|
||||
|
||||
// Get the last part of the path
|
||||
const base = path.split('/').pop() || '';
|
||||
|
||||
// If extension is provided, remove it from the result
|
||||
if (ext && base.endsWith(ext)) {
|
||||
return base.slice(0, -ext.length);
|
||||
}
|
||||
|
||||
return base;
|
||||
},
|
||||
relative: (from: string, to: string): string => {
|
||||
// Handle empty inputs
|
||||
if (!from || !to) {
|
||||
return '.';
|
||||
}
|
||||
|
||||
// Normalize paths by removing trailing slashes and splitting
|
||||
const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean);
|
||||
|
||||
const fromParts = normalizePathParts(from);
|
||||
const toParts = normalizePathParts(to);
|
||||
|
||||
// Find common parts at the start of both paths
|
||||
let commonLength = 0;
|
||||
const minLength = Math.min(fromParts.length, toParts.length);
|
||||
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
if (fromParts[i] !== toParts[i]) {
|
||||
break;
|
||||
}
|
||||
|
||||
commonLength++;
|
||||
}
|
||||
|
||||
// Calculate the number of "../" needed
|
||||
const upCount = fromParts.length - commonLength;
|
||||
|
||||
// Get the remaining path parts we need to append
|
||||
const remainingPath = toParts.slice(commonLength);
|
||||
|
||||
// Construct the relative path
|
||||
const relativeParts = [...Array(upCount).fill('..'), ...remainingPath];
|
||||
|
||||
// Handle empty result case
|
||||
return relativeParts.length === 0 ? '.' : relativeParts.join('/');
|
||||
},
|
||||
};
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -37,6 +38,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -96,6 +98,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -104,6 +107,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -112,6 +116,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -120,6 +125,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -128,6 +134,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": "bundled",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -136,6 +143,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": "bundled",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -144,6 +152,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -152,6 +161,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -160,6 +170,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -168,6 +179,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -176,6 +188,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -184,6 +197,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -192,6 +206,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -200,6 +215,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -208,6 +224,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -216,5 +233,6 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
|
||||
"id": "artifact_1",
|
||||
"messageId": "message_1",
|
||||
"title": "Some title",
|
||||
"type": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -59,7 +59,11 @@ describe('StreamingMessageParser', () => {
|
||||
},
|
||||
],
|
||||
[
|
||||
['Some text before <boltArti', 'fact', ' title="Some title" id="artifact_1">foo</boltArtifact> Some more text'],
|
||||
[
|
||||
'Some text before <boltArti',
|
||||
'fact',
|
||||
' title="Some title" id="artifact_1" type="bundled" >foo</boltArtifact> Some more text',
|
||||
],
|
||||
{
|
||||
output: 'Some text before Some more text',
|
||||
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
|
||||
|
||||
@@ -192,6 +192,7 @@ export class StreamingMessageParser {
|
||||
const artifactTag = input.slice(i, openTagEnd + 1);
|
||||
|
||||
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
|
||||
const type = this.#extractAttribute(artifactTag, 'type') as string;
|
||||
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
|
||||
|
||||
if (!artifactTitle) {
|
||||
@@ -207,6 +208,7 @@ export class StreamingMessageParser {
|
||||
const currentArtifact = {
|
||||
id: artifactId,
|
||||
title: artifactTitle,
|
||||
type,
|
||||
} satisfies BoltArtifactData;
|
||||
|
||||
state.currentArtifact = currentArtifact;
|
||||
|
||||
149
app/lib/stores/logs.ts
Normal file
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,9 +17,10 @@ export interface Shortcuts {
|
||||
toggleTerminal: Shortcut;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
shortcuts: Shortcuts;
|
||||
}
|
||||
export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
|
||||
export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama'];
|
||||
|
||||
export type ProviderSetting = Record<string, IProviderConfig>;
|
||||
|
||||
export const shortcutsStore = map<Shortcuts>({
|
||||
toggleTerminal: {
|
||||
@@ -27,13 +30,19 @@ export const shortcutsStore = map<Shortcuts>({
|
||||
},
|
||||
});
|
||||
|
||||
export const settingsStore = map<Settings>({
|
||||
shortcuts: shortcutsStore.get(),
|
||||
const initialProviderSettings: ProviderSetting = {};
|
||||
PROVIDER_LIST.forEach((provider) => {
|
||||
initialProviderSettings[provider.name] = {
|
||||
...provider,
|
||||
settings: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
export const providersStore = map<ProviderSetting>(initialProviderSettings);
|
||||
|
||||
shortcutsStore.subscribe((shortcuts) => {
|
||||
settingsStore.set({
|
||||
...settingsStore.get(),
|
||||
shortcuts,
|
||||
});
|
||||
});
|
||||
export const isDebugMode = atom(false);
|
||||
|
||||
export const isEventLogsEnabled = atom(false);
|
||||
|
||||
export const isLocalModelsEnabled = atom(true);
|
||||
|
||||
@@ -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,10 +15,12 @@ import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
|
||||
import * as nodePath from 'node:path';
|
||||
import { extractRelativePath } from '~/utils/diff';
|
||||
import { description } from '~/lib/persistence';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export interface ArtifactState {
|
||||
id: string;
|
||||
title: string;
|
||||
type?: string;
|
||||
closed: boolean;
|
||||
runner: ActionRunner;
|
||||
}
|
||||
@@ -230,7 +232,7 @@ export class WorkbenchStore {
|
||||
// TODO: what do we wanna do and how do we wanna recover from this?
|
||||
}
|
||||
|
||||
addArtifact({ messageId, title, id }: ArtifactCallbackData) {
|
||||
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
|
||||
const artifact = this.#getArtifact(messageId);
|
||||
|
||||
if (artifact) {
|
||||
@@ -245,6 +247,7 @@ export class WorkbenchStore {
|
||||
id,
|
||||
title,
|
||||
closed: false,
|
||||
type,
|
||||
runner: new ActionRunner(webcontainer, () => this.boltTerminal),
|
||||
});
|
||||
}
|
||||
@@ -394,15 +397,14 @@ export class WorkbenchStore {
|
||||
return syncedFiles;
|
||||
}
|
||||
|
||||
async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) {
|
||||
async pushToGitHub(repoName: string, githubUsername?: string, ghToken?: string) {
|
||||
try {
|
||||
// Get the GitHub auth token from environment variables
|
||||
const githubToken = ghToken;
|
||||
// Use cookies if username and token are not provided
|
||||
const githubToken = ghToken || Cookies.get('githubToken');
|
||||
const owner = githubUsername || Cookies.get('githubUsername');
|
||||
|
||||
const owner = githubUsername;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error('GitHub token is not set in environment variables');
|
||||
if (!githubToken || !owner) {
|
||||
throw new Error('GitHub token or username is not set in cookies or provided.');
|
||||
}
|
||||
|
||||
// Initialize Octokit with the auth token
|
||||
@@ -499,7 +501,8 @@ export class WorkbenchStore {
|
||||
|
||||
alert(`Repository created and code pushed: ${repo.html_url}`);
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error));
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
throw error; // Rethrow the error for further handling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck – TODO: Provider proper types
|
||||
|
||||
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
|
||||
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
|
||||
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
|
||||
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
|
||||
import type { IProviderSetting } from '~/types/model';
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return chatAction(args);
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
function parseCookies(cookieHeader: string) {
|
||||
const cookies: any = {};
|
||||
|
||||
// Split the cookie string by semicolons and spaces
|
||||
const items = cookieHeader.split(';').map((cookie) => cookie.trim());
|
||||
@@ -32,7 +30,7 @@ function parseCookies(cookieHeader) {
|
||||
}
|
||||
|
||||
async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const { messages, model } = await request.json<{
|
||||
const { messages } = await request.json<{
|
||||
messages: Messages;
|
||||
model: string;
|
||||
}>();
|
||||
@@ -40,15 +38,16 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
const cookieHeader = request.headers.get('Cookie');
|
||||
|
||||
// Parse the cookie's value (returns an object or null if no cookie exists)
|
||||
const apiKeys = JSON.parse(parseCookies(cookieHeader).apiKeys || '{}');
|
||||
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
|
||||
const providerSettings: Record<string, IProviderSetting> = JSON.parse(
|
||||
parseCookies(cookieHeader || '').providers || '{}',
|
||||
);
|
||||
|
||||
const stream = new SwitchableStream();
|
||||
|
||||
try {
|
||||
const options: StreamingOptions = {
|
||||
toolChoice: 'none',
|
||||
apiKeys,
|
||||
model,
|
||||
onFinish: async ({ text: content, finishReason }) => {
|
||||
if (finishReason !== 'length') {
|
||||
return stream.close();
|
||||
@@ -65,13 +64,13 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
messages.push({ role: 'assistant', content });
|
||||
messages.push({ role: 'user', content: CONTINUE_PROMPT });
|
||||
|
||||
const result = await streamText(messages, context.cloudflare.env, options);
|
||||
const result = await streamText({ messages, env: context.cloudflare.env, options, apiKeys, providerSettings });
|
||||
|
||||
return stream.switchSource(result.toAIStream());
|
||||
},
|
||||
};
|
||||
|
||||
const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
|
||||
const result = await streamText({ messages, env: context.cloudflare.env, options, apiKeys, providerSettings });
|
||||
|
||||
stream.switchSource(result.toAIStream());
|
||||
|
||||
@@ -81,7 +80,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
|
||||
if (error.message?.includes('API key')) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { StreamingTextResponse, parseStreamPart } from 'ai';
|
||||
import { streamText } from '~/lib/.server/llm/stream-text';
|
||||
import { stripIndents } from '~/utils/stripIndent';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import type { IProviderSetting, ProviderInfo } from '~/types/model';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -11,8 +11,28 @@ export async function action(args: ActionFunctionArgs) {
|
||||
return enhancerAction(args);
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader: string) {
|
||||
const cookies: any = {};
|
||||
|
||||
// Split the cookie string by semicolons and spaces
|
||||
const items = cookieHeader.split(';').map((cookie) => cookie.trim());
|
||||
|
||||
items.forEach((item) => {
|
||||
const [name, ...rest] = item.split('=');
|
||||
|
||||
if (name && rest) {
|
||||
// Decode the name and value, and join value parts in case it contains '='
|
||||
const decodedName = decodeURIComponent(name.trim());
|
||||
const decodedValue = decodeURIComponent(rest.join('=').trim());
|
||||
cookies[decodedName] = decodedValue;
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
const { message, model, provider, apiKeys } = await request.json<{
|
||||
const { message, model, provider } = await request.json<{
|
||||
message: string;
|
||||
model: string;
|
||||
provider: ProviderInfo;
|
||||
@@ -36,9 +56,17 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const cookieHeader = request.headers.get('Cookie');
|
||||
|
||||
// Parse the cookie's value (returns an object or null if no cookie exists)
|
||||
const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
|
||||
const providerSettings: Record<string, IProviderSetting> = JSON.parse(
|
||||
parseCookies(cookieHeader || '').providers || '{}',
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await streamText(
|
||||
[
|
||||
const result = await streamText({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
@@ -73,10 +101,10 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||
`,
|
||||
},
|
||||
],
|
||||
context.cloudflare.env,
|
||||
undefined,
|
||||
env: context.cloudflare.env,
|
||||
apiKeys,
|
||||
);
|
||||
providerSettings,
|
||||
});
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
|
||||
23
app/routes/git.tsx
Normal file
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));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface BoltArtifactData {
|
||||
id: string;
|
||||
title: string;
|
||||
type?: string | undefined;
|
||||
}
|
||||
|
||||
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,8 +3,17 @@ import type { ModelInfo } from '~/utils/types';
|
||||
export type ProviderInfo = {
|
||||
staticModels: ModelInfo[];
|
||||
name: string;
|
||||
getDynamicModels?: () => Promise<ModelInfo[]>;
|
||||
getDynamicModels?: (apiKeys?: Record<string, string>, providerSettings?: IProviderSetting) => Promise<ModelInfo[]>;
|
||||
getApiKeyLink?: string;
|
||||
labelForGetApiKey?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export interface IProviderSetting {
|
||||
enabled?: boolean;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export type IProviderConfig = ProviderInfo & {
|
||||
settings: IProviderSetting;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import type { ProviderInfo, IProviderSetting } from '~/types/model';
|
||||
import { createScopedLogger } from './logger';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
export const WORK_DIR_NAME = 'project';
|
||||
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
|
||||
@@ -9,6 +12,8 @@ export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;
|
||||
export const DEFAULT_MODEL = 'claude-3-5-sonnet-latest';
|
||||
export const PROMPT_COOKIE_KEY = 'cachedPrompt';
|
||||
|
||||
const logger = createScopedLogger('Constants');
|
||||
|
||||
const PROVIDER_LIST: ProviderInfo[] = [
|
||||
{
|
||||
name: 'Anthropic',
|
||||
@@ -122,11 +127,12 @@ const PROVIDER_LIST: ProviderInfo[] = [
|
||||
name: 'Google',
|
||||
staticModels: [
|
||||
{ name: 'gemini-1.5-flash-latest', label: 'Gemini 1.5 Flash', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-flash-002', label: 'Gemini 1.5 Flash-002', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-flash-8b', label: 'Gemini 1.5 Flash-8b', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-pro-latest', label: 'Gemini 1.5 Pro', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-1.5-pro-002', label: 'Gemini 1.5 Pro-002', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-exp-1121', label: 'Gemini exp-1121', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
{ name: 'gemini-exp-1206', label: 'Gemini exp-1206', provider: 'Google', maxTokenAllowed: 8192 },
|
||||
],
|
||||
getApiKeyLink: 'https://aistudio.google.com/app/apikey',
|
||||
},
|
||||
@@ -262,6 +268,7 @@ const PROVIDER_LIST: ProviderInfo[] = [
|
||||
},
|
||||
{
|
||||
name: 'Together',
|
||||
getDynamicModels: getTogetherModels,
|
||||
staticModels: [
|
||||
{
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
@@ -293,8 +300,66 @@ const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat(
|
||||
|
||||
export let MODEL_LIST: ModelInfo[] = [...staticModels];
|
||||
|
||||
const getOllamaBaseUrl = () => {
|
||||
const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
|
||||
export async function getModelList(
|
||||
apiKeys: Record<string, string>,
|
||||
providerSettings?: Record<string, IProviderSetting>,
|
||||
) {
|
||||
MODEL_LIST = [
|
||||
...(
|
||||
await Promise.all(
|
||||
PROVIDER_LIST.filter(
|
||||
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
|
||||
).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])),
|
||||
)
|
||||
).flat(),
|
||||
...staticModels,
|
||||
];
|
||||
return MODEL_LIST;
|
||||
}
|
||||
|
||||
async function getTogetherModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const baseUrl = settings?.baseUrl || import.meta.env.TOGETHER_API_BASE_URL || '';
|
||||
const provider = 'Together';
|
||||
|
||||
if (!baseUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
|
||||
|
||||
if (apiKeys && apiKeys[provider]) {
|
||||
apiKey = apiKeys[provider];
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
const res = (await response.json()) as any;
|
||||
const data: any[] = (res || []).filter((model: any) => model.type == 'chat');
|
||||
|
||||
return data.map((m: any) => ({
|
||||
name: m.id,
|
||||
label: `${m.display_name} - in:$${m.pricing.input.toFixed(
|
||||
2,
|
||||
)} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
|
||||
provider,
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error getting OpenAILike models:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const getOllamaBaseUrl = (settings?: IProviderSetting) => {
|
||||
const defaultBaseUrl = settings?.baseUrl || import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
|
||||
|
||||
// Check if we're in the browser
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -308,15 +373,9 @@ const getOllamaBaseUrl = () => {
|
||||
return isDocker ? defaultBaseUrl.replace('localhost', 'host.docker.internal') : defaultBaseUrl;
|
||||
};
|
||||
|
||||
async function getOllamaModels(): Promise<ModelInfo[]> {
|
||||
/*
|
||||
* if (typeof window === 'undefined') {
|
||||
* return [];
|
||||
* }
|
||||
*/
|
||||
|
||||
async function getOllamaModels(apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const baseUrl = getOllamaBaseUrl();
|
||||
const baseUrl = getOllamaBaseUrl(settings);
|
||||
const response = await fetch(`${baseUrl}/api/tags`);
|
||||
const data = (await response.json()) as OllamaApiResponse;
|
||||
|
||||
@@ -326,21 +385,31 @@ async function getOllamaModels(): Promise<ModelInfo[]> {
|
||||
provider: 'Ollama',
|
||||
maxTokenAllowed: 8000,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error getting Ollama models:', e);
|
||||
} catch (e: any) {
|
||||
logStore.logError('Failed to get Ollama models', e, { baseUrl: settings?.baseUrl });
|
||||
logger.warn('Failed to get Ollama models: ', e.message || '');
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getOpenAILikeModels(): Promise<ModelInfo[]> {
|
||||
async function getOpenAILikeModels(
|
||||
apiKeys?: Record<string, string>,
|
||||
settings?: IProviderSetting,
|
||||
): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const baseUrl = import.meta.env.OPENAI_LIKE_API_BASE_URL || '';
|
||||
const baseUrl = settings?.baseUrl || import.meta.env.OPENAI_LIKE_API_BASE_URL || '';
|
||||
|
||||
if (!baseUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
|
||||
let apiKey = '';
|
||||
|
||||
if (apiKeys && apiKeys.OpenAILike) {
|
||||
apiKey = apiKeys.OpenAILike;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
@@ -392,13 +461,10 @@ async function getOpenRouterModels(): Promise<ModelInfo[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
async function getLMStudioModels(): Promise<ModelInfo[]> {
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
async function getLMStudioModels(_apiKeys?: Record<string, string>, settings?: IProviderSetting): Promise<ModelInfo[]> {
|
||||
|
||||
try {
|
||||
const baseUrl = import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
|
||||
const baseUrl = settings?.baseUrl || import.meta.env.LMSTUDIO_API_BASE_URL || 'http://localhost:1234';
|
||||
const response = await fetch(`${baseUrl}/v1/models`);
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
@@ -407,23 +473,42 @@ async function getLMStudioModels(): Promise<ModelInfo[]> {
|
||||
label: model.id,
|
||||
provider: 'LMStudio',
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error getting LMStudio models:', e);
|
||||
} catch (e: any) {
|
||||
logStore.logError('Failed to get LMStudio models', e, { baseUrl: settings?.baseUrl });
|
||||
logger.warn('Failed to get LMStudio models: ', e.message || '');
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeModelList(): Promise<ModelInfo[]> {
|
||||
async function initializeModelList(providerSettings?: Record<string, IProviderSetting>): Promise<ModelInfo[]> {
|
||||
let apiKeys: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (storedApiKeys) {
|
||||
const parsedKeys = JSON.parse(storedApiKeys);
|
||||
|
||||
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
|
||||
apiKeys = parsedKeys;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logStore.logError('Failed to fetch API keys from cookies', error);
|
||||
logger.warn(`Failed to fetch apikeys from cookies: ${error?.message}`);
|
||||
}
|
||||
MODEL_LIST = [
|
||||
...(
|
||||
await Promise.all(
|
||||
PROVIDER_LIST.filter(
|
||||
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
|
||||
).map((p) => p.getDynamicModels()),
|
||||
).map((p) => p.getDynamicModels(apiKeys, providerSettings?.[p.name])),
|
||||
)
|
||||
).flat(),
|
||||
...staticModels,
|
||||
];
|
||||
|
||||
return MODEL_LIST;
|
||||
}
|
||||
|
||||
|
||||
105
app/utils/fileUtils.ts
Normal file
105
app/utils/fileUtils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import ignore from 'ignore';
|
||||
|
||||
// Common patterns to ignore, similar to .gitignore
|
||||
export const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
];
|
||||
|
||||
export const MAX_FILES = 1000;
|
||||
export const ig = ignore().add(IGNORE_PATTERNS);
|
||||
|
||||
export const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
|
||||
export const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||
const chunkSize = 1024;
|
||||
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const shouldIncludeFile = (path: string): boolean => {
|
||||
return !ig.ignores(path);
|
||||
};
|
||||
|
||||
const readPackageJson = async (files: File[]): Promise<{ scripts?: Record<string, string> } | null> => {
|
||||
const packageJsonFile = files.find((f) => f.webkitRelativePath.endsWith('package.json'));
|
||||
|
||||
if (!packageJsonFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(packageJsonFile);
|
||||
});
|
||||
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error('Error reading package.json:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const detectProjectType = async (
|
||||
files: File[],
|
||||
): Promise<{ type: string; setupCommand: string; followupMessage: string }> => {
|
||||
const hasFile = (name: string) => files.some((f) => f.webkitRelativePath.endsWith(name));
|
||||
|
||||
if (hasFile('package.json')) {
|
||||
const packageJson = await readPackageJson(files);
|
||||
const scripts = packageJson?.scripts || {};
|
||||
|
||||
// Check for preferred commands in priority order
|
||||
const preferredCommands = ['dev', 'start', 'preview'];
|
||||
const availableCommand = preferredCommands.find((cmd) => scripts[cmd]);
|
||||
|
||||
if (availableCommand) {
|
||||
return {
|
||||
type: 'Node.js',
|
||||
setupCommand: `npm install && npm run ${availableCommand}`,
|
||||
followupMessage: `Found "${availableCommand}" script in package.json. Running "npm run ${availableCommand}" after installation.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Node.js',
|
||||
setupCommand: 'npm install',
|
||||
followupMessage:
|
||||
'Would you like me to inspect package.json to determine the available scripts for running this project?',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasFile('index.html')) {
|
||||
return {
|
||||
type: 'Static',
|
||||
setupCommand: 'npx --yes serve',
|
||||
followupMessage: '',
|
||||
};
|
||||
}
|
||||
|
||||
return { type: '', setupCommand: '', followupMessage: '' };
|
||||
};
|
||||
68
app/utils/folderImport.ts
Normal file
68
app/utils/folderImport.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Message } from 'ai';
|
||||
import { generateId } from './fileUtils';
|
||||
import { detectProjectCommands, createCommandsMessage } from './projectCommands';
|
||||
|
||||
export const createChatFromFolder = async (
|
||||
files: File[],
|
||||
binaryFiles: string[],
|
||||
folderName: string,
|
||||
): Promise<Message[]> => {
|
||||
const fileArtifacts = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return new Promise<{ content: string; path: string }>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string;
|
||||
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
||||
resolve({
|
||||
content,
|
||||
path: relativePath,
|
||||
});
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const commands = await detectProjectCommands(fileArtifacts);
|
||||
const commandsMessage = createCommandsMessage(commands);
|
||||
|
||||
const binaryFilesMessage =
|
||||
binaryFiles.length > 0
|
||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}
|
||||
|
||||
<boltArtifact id="imported-files" title="Imported Files">
|
||||
${fileArtifacts
|
||||
.map(
|
||||
(file) => `<boltAction type="file" filePath="${file.path}">
|
||||
${file.content}
|
||||
</boltAction>`,
|
||||
)
|
||||
.join('\n\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
id: generateId(),
|
||||
content: `Import the "${folderName}" folder`,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const messages = [userMessage, filesMessage];
|
||||
|
||||
if (commandsMessage) {
|
||||
messages.push(commandsMessage);
|
||||
}
|
||||
|
||||
return messages;
|
||||
};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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