Merge branch 'main' into prompt-url-params
This commit is contained in:
@@ -1,13 +1,30 @@
|
||||
import { memo } from 'react';
|
||||
import { Markdown } from './Markdown';
|
||||
import type { JSONValue } from 'ai';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
annotations?: JSONValue[];
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
|
||||
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
|
||||
const filteredAnnotations = (annotations?.filter(
|
||||
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
||||
) || []) as { type: string; value: any }[];
|
||||
|
||||
const usage: {
|
||||
completionTokens: number;
|
||||
promptTokens: number;
|
||||
totalTokens: number;
|
||||
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
{usage && (
|
||||
<div className="text-sm text-bolt-elements-textSecondary mb-2">
|
||||
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
|
||||
</div>
|
||||
)}
|
||||
<Markdown html>{content}</Markdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ 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';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@@ -75,7 +77,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
input = '',
|
||||
enhancingPrompt,
|
||||
handleInputChange,
|
||||
promptEnhanced,
|
||||
|
||||
// promptEnhanced,
|
||||
enhancePrompt,
|
||||
sendMessage,
|
||||
handleStop,
|
||||
@@ -283,7 +286,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<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>
|
||||
@@ -376,6 +379,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<ScreenshotStateManager
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
setImageDataList={setImageDataList}
|
||||
uploadedFiles={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<div
|
||||
className={classNames(
|
||||
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
||||
@@ -384,7 +397,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',
|
||||
)}
|
||||
@@ -431,6 +444,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore if using input method engine
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
@@ -473,25 +491,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<IconButton
|
||||
title="Enhance prompt"
|
||||
disabled={input.length === 0 || enhancingPrompt}
|
||||
className={classNames(
|
||||
'transition-all',
|
||||
enhancingPrompt ? 'opacity-100' : '',
|
||||
promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
|
||||
promptEnhanced ? 'pr-1.5' : '',
|
||||
promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
|
||||
)}
|
||||
onClick={() => enhancePrompt?.()}
|
||||
className={classNames('transition-all', enhancingPrompt ? 'opacity-100' : '')}
|
||||
onClick={() => {
|
||||
enhancePrompt?.();
|
||||
toast.success('Prompt enhanced!');
|
||||
}}
|
||||
>
|
||||
{enhancingPrompt ? (
|
||||
<>
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
|
||||
<div className="ml-1.5">Enhancing prompt...</div>
|
||||
</>
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-bolt:stars text-xl"></div>
|
||||
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
|
||||
</>
|
||||
<div className="i-bolt:stars text-xl"></div>
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
|
||||
@@ -93,8 +93,9 @@ 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 [searchParams, setSearchParams] = useSearchParams();
|
||||
const files = useStore(workbenchStore.files);
|
||||
const { activeProviders, promptId } = useSettings();
|
||||
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
@@ -115,14 +116,25 @@ export const ChatImpl = memo(
|
||||
api: '/api/chat',
|
||||
body: {
|
||||
apiKeys,
|
||||
files,
|
||||
promptId,
|
||||
},
|
||||
sendExtraMessageFields: true,
|
||||
onError: (error) => {
|
||||
logger.error('Request failed\n\n', error);
|
||||
toast.error(
|
||||
'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
|
||||
);
|
||||
},
|
||||
onFinish: () => {
|
||||
onFinish: (message, response) => {
|
||||
const usage = response.usage;
|
||||
|
||||
if (usage) {
|
||||
console.log('Token usage:', usage);
|
||||
|
||||
// You can now use the usage data as needed
|
||||
}
|
||||
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
initialMessages,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import ignore from 'ignore';
|
||||
import { useGit } from '~/lib/hooks/useGit';
|
||||
import type { Message } from 'ai';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { detectProjectCommands, createCommandsMessage } from '~/utils/projectCommands';
|
||||
import { generateId } from '~/utils/fileUtils';
|
||||
|
||||
@@ -73,7 +72,7 @@ export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
|
||||
const filesMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: `Cloning the repo ${repoUrl} into ${workdir}
|
||||
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
|
||||
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
|
||||
${fileContents
|
||||
.map(
|
||||
(file) =>
|
||||
@@ -99,17 +98,13 @@ ${file.content}
|
||||
};
|
||||
|
||||
return (
|
||||
<WithTooltip tooltip="Clone A Git Repo">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
}}
|
||||
title="Clone A Git Repo"
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="i-ph:git-branch" />
|
||||
Clone A Git Repo
|
||||
</button>
|
||||
</WithTooltip>
|
||||
<button
|
||||
onClick={onClick}
|
||||
title="Clone a Git Repo"
|
||||
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
|
||||
>
|
||||
<span className="i-ph:git-branch" />
|
||||
Clone a Git Repo
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
|
||||
import { createChatFromFolder } from '~/utils/folderImport';
|
||||
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
||||
|
||||
interface ImportFolderButtonProps {
|
||||
className?: string;
|
||||
@@ -16,9 +17,15 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
const allFiles = Array.from(e.target.files || []);
|
||||
|
||||
if (allFiles.length > MAX_FILES) {
|
||||
const error = new Error(`Too many files: ${allFiles.length}`);
|
||||
logStore.logError('File import failed - too many files', error, {
|
||||
fileCount: allFiles.length,
|
||||
maxFiles: MAX_FILES,
|
||||
});
|
||||
toast.error(
|
||||
`This folder contains ${allFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,7 +38,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||
|
||||
if (filteredFiles.length === 0) {
|
||||
const error = new Error('No valid files found');
|
||||
logStore.logError('File import failed - no valid files', error, { folderName });
|
||||
toast.error('No files found in the selected folder');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,11 +58,18 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
const error = new Error('No text files found');
|
||||
logStore.logError('File import failed - no text files', error, { folderName });
|
||||
toast.error('No text files found in the selected folder');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (binaryFilePaths.length > 0) {
|
||||
logStore.logWarning(`Skipping binary files during import`, {
|
||||
folderName,
|
||||
binaryCount: binaryFilePaths.length,
|
||||
});
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
@@ -62,8 +79,14 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
await importChat(folderName, [...messages]);
|
||||
}
|
||||
|
||||
logStore.logSystem('Folder imported successfully', {
|
||||
folderName,
|
||||
textFileCount: textFiles.length,
|
||||
binaryFileCount: binaryFilePaths.length,
|
||||
});
|
||||
toast.success('Folder imported successfully');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to import folder', error, { folderName });
|
||||
console.error('Failed to import folder:', error);
|
||||
toast.error('Failed to import folder');
|
||||
} finally {
|
||||
|
||||
@@ -65,12 +65,16 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-col-1 w-full">
|
||||
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
|
||||
{isUserMessage ? (
|
||||
<UserMessage content={content} />
|
||||
) : (
|
||||
<AssistantMessage content={content} annotations={message.annotations} />
|
||||
)}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
{messageId && (
|
||||
{messageId && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<button
|
||||
onClick={() => handleRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
@@ -79,8 +83,8 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</WithTooltip>
|
||||
</WithTooltip>
|
||||
)}
|
||||
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import type { ModelInfo } from '~/utils/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
@@ -22,62 +21,28 @@ export const ModelSelector = ({
|
||||
providerList,
|
||||
}: ModelSelectorProps) => {
|
||||
// Load enabled providers from cookies
|
||||
const [enabledProviders, setEnabledProviders] = useState(() => {
|
||||
const savedProviders = Cookies.get('providers');
|
||||
|
||||
if (savedProviders) {
|
||||
try {
|
||||
const parsedProviders = JSON.parse(savedProviders);
|
||||
return providerList.filter((p) => parsedProviders[p.name]);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse providers from cookies:', error);
|
||||
return providerList;
|
||||
}
|
||||
}
|
||||
|
||||
return providerList;
|
||||
});
|
||||
|
||||
// Update enabled providers when cookies change
|
||||
useEffect(() => {
|
||||
// Function to update providers from cookies
|
||||
const updateProvidersFromCookies = () => {
|
||||
const savedProviders = Cookies.get('providers');
|
||||
// If current provider is disabled, switch to first enabled provider
|
||||
if (providerList.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedProviders) {
|
||||
try {
|
||||
const parsedProviders = JSON.parse(savedProviders);
|
||||
const newEnabledProviders = providerList.filter((p) => parsedProviders[p.name]);
|
||||
setEnabledProviders(newEnabledProviders);
|
||||
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
|
||||
const firstEnabledProvider = providerList[0];
|
||||
setProvider?.(firstEnabledProvider);
|
||||
|
||||
// If current provider is disabled, switch to first enabled provider
|
||||
if (provider && !parsedProviders[provider.name] && newEnabledProviders.length > 0) {
|
||||
const firstEnabledProvider = newEnabledProviders[0];
|
||||
setProvider?.(firstEnabledProvider);
|
||||
// Also update the model to the first available one for the new provider
|
||||
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
||||
|
||||
// Also update the model to the first available one for the new provider
|
||||
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
||||
|
||||
if (firstModel) {
|
||||
setModel?.(firstModel.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse providers from cookies:', error);
|
||||
}
|
||||
if (firstModel) {
|
||||
setModel?.(firstModel.name);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
updateProvidersFromCookies();
|
||||
|
||||
// Set up an interval to check for cookie changes
|
||||
const interval = setInterval(updateProvidersFromCookies, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [providerList, provider, setProvider, modelList, setModel]);
|
||||
|
||||
if (enabledProviders.length === 0) {
|
||||
if (providerList.length === 0) {
|
||||
return (
|
||||
<div className="mb-2 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary">
|
||||
<p className="text-center">
|
||||
@@ -93,7 +58,7 @@ export const ModelSelector = ({
|
||||
<select
|
||||
value={provider?.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const newProvider = enabledProviders.find((p: ProviderInfo) => p.name === e.target.value);
|
||||
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
||||
|
||||
if (newProvider && setProvider) {
|
||||
setProvider(newProvider);
|
||||
@@ -107,7 +72,7 @@ export const ModelSelector = ({
|
||||
}}
|
||||
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
|
||||
>
|
||||
{enabledProviders.map((provider: ProviderInfo) => (
|
||||
{providerList.map((provider: ProviderInfo) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.name}
|
||||
</option>
|
||||
|
||||
33
app/components/chat/ScreenshotStateManager.tsx
Normal file
33
app/components/chat/ScreenshotStateManager.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ScreenshotStateManagerProps {
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
uploadedFiles: File[];
|
||||
imageDataList: string[];
|
||||
}
|
||||
|
||||
export const ScreenshotStateManager = ({
|
||||
setUploadedFiles,
|
||||
setImageDataList,
|
||||
uploadedFiles,
|
||||
imageDataList,
|
||||
}: ScreenshotStateManagerProps) => {
|
||||
useEffect(() => {
|
||||
if (setUploadedFiles && setImageDataList) {
|
||||
(window as any).__BOLT_SET_UPLOADED_FILES__ = setUploadedFiles;
|
||||
(window as any).__BOLT_SET_IMAGE_DATA_LIST__ = setImageDataList;
|
||||
(window as any).__BOLT_UPLOADED_FILES__ = uploadedFiles;
|
||||
(window as any).__BOLT_IMAGE_DATA_LIST__ = imageDataList;
|
||||
}
|
||||
|
||||
return () => {
|
||||
delete (window as any).__BOLT_SET_UPLOADED_FILES__;
|
||||
delete (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
|
||||
delete (window as any).__BOLT_UPLOADED_FILES__;
|
||||
delete (window as any).__BOLT_IMAGE_DATA_LIST__;
|
||||
};
|
||||
}, [setUploadedFiles, setImageDataList, uploadedFiles, imageDataList]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -12,42 +12,36 @@ interface UserMessageProps {
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
if (Array.isArray(content)) {
|
||||
const textItem = content.find((item) => item.type === 'text');
|
||||
const textContent = sanitizeUserMessage(textItem?.text || '');
|
||||
const textContent = stripMetadata(textItem?.text || '');
|
||||
const images = content.filter((item) => item.type === 'image' && item.image);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||
</div>
|
||||
{images.length > 0 && (
|
||||
<div className="flex-shrink-0 w-[160px]">
|
||||
{images.map((item, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={`Uploaded image ${index + 1}`}
|
||||
className="w-full h-[160px] rounded-lg object-cover border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
{textContent && <Markdown html>{textContent}</Markdown>}
|
||||
{images.map((item, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={item.image}
|
||||
alt={`Image ${index + 1}`}
|
||||
className="max-w-full h-auto rounded-lg"
|
||||
style={{ maxHeight: '512px', objectFit: 'contain' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = sanitizeUserMessage(content);
|
||||
const textContent = stripMetadata(content);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||
<Markdown html>{textContent}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeUserMessage(content: string) {
|
||||
function stripMetadata(content: string) {
|
||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import React from 'react';
|
||||
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
|
||||
|
||||
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
@@ -17,18 +18,17 @@ interface SettingsProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'connection';
|
||||
type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
|
||||
|
||||
// Providers that support base URL configuration
|
||||
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
||||
const { debug } = useSettings();
|
||||
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 /> },
|
||||
{ id: 'features', label: 'Features', icon: 'i-ph:star', component: <FeaturesTab /> },
|
||||
...(debug
|
||||
? [
|
||||
{
|
||||
@@ -39,6 +39,16 @@ export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(eventLogs
|
||||
? [
|
||||
{
|
||||
id: 'event-logs' as TabType,
|
||||
label: 'Event Logs',
|
||||
icon: 'i-ph:list-bullets',
|
||||
component: <EventLogsTab />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ 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();
|
||||
@@ -21,8 +22,17 @@ export default function ChatHistoryTab() {
|
||||
};
|
||||
|
||||
const handleDeleteAllChats = async () => {
|
||||
const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.');
|
||||
|
||||
if (!confirmDelete) {
|
||||
return; // Exit if the user cancels
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,13 +40,12 @@ export default function ChatHistoryTab() {
|
||||
setIsDeleting(true);
|
||||
|
||||
const allChats = await getAll(db);
|
||||
|
||||
// Delete all chats one by one
|
||||
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 {
|
||||
@@ -46,7 +55,10 @@ export default function ChatHistoryTab() {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -58,8 +70,10 @@ export default function ChatHistoryTab() {
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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') || '');
|
||||
@@ -9,7 +10,12 @@ export default function ConnectionsTab() {
|
||||
const handleSaveConnection = () => {
|
||||
Cookies.set('githubUsername', githubUsername);
|
||||
Cookies.set('githubToken', githubToken);
|
||||
logStore.logSystem('GitHub connection settings updated', {
|
||||
username: githubUsername,
|
||||
hasToken: !!githubToken,
|
||||
});
|
||||
toast.success('GitHub credentials saved successfully!');
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,69 +1,620 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import commit from '~/commit.json';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const versionHash = commit.commit; // Get the version hash 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;
|
||||
deviceType: string;
|
||||
colorDepth: string;
|
||||
pixelRatio: number;
|
||||
online: boolean;
|
||||
cookiesEnabled: boolean;
|
||||
doNotTrack: boolean;
|
||||
}
|
||||
|
||||
interface IProviderConfig {
|
||||
name: string;
|
||||
settings: {
|
||||
enabled: boolean;
|
||||
baseUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CommitData {
|
||||
commit: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
const connitJson: CommitData = commit;
|
||||
|
||||
const LOCAL_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
|
||||
const versionHash = connitJson.commit;
|
||||
const versionTag = connitJson.version;
|
||||
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',
|
||||
commitJson: (branch: string) =>
|
||||
`https://raw.githubusercontent.com/stackblitz-labs/bolt.diy/${branch}/app/commit.json`,
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
const getBrowserInfo = (): string => {
|
||||
const ua = navigator.userAgent;
|
||||
let browser = 'Unknown';
|
||||
|
||||
if (ua.includes('Firefox/')) {
|
||||
browser = 'Firefox';
|
||||
} else if (ua.includes('Chrome/')) {
|
||||
if (ua.includes('Edg/')) {
|
||||
browser = 'Edge';
|
||||
} else if (ua.includes('OPR/')) {
|
||||
browser = 'Opera';
|
||||
} else {
|
||||
browser = 'Chrome';
|
||||
}
|
||||
} else if (ua.includes('Safari/')) {
|
||||
if (!ua.includes('Chrome')) {
|
||||
browser = 'Safari';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract version number
|
||||
const match = ua.match(new RegExp(`${browser}\\/([\\d.]+)`));
|
||||
const version = match ? ` ${match[1]}` : '';
|
||||
|
||||
return `${browser}${version}`;
|
||||
};
|
||||
|
||||
const getOperatingSystem = (): string => {
|
||||
const ua = navigator.userAgent;
|
||||
const platform = navigator.platform;
|
||||
|
||||
if (ua.includes('Win')) {
|
||||
return 'Windows';
|
||||
}
|
||||
|
||||
if (ua.includes('Mac')) {
|
||||
if (ua.includes('iPhone') || ua.includes('iPad')) {
|
||||
return 'iOS';
|
||||
}
|
||||
|
||||
return 'macOS';
|
||||
}
|
||||
|
||||
if (ua.includes('Linux')) {
|
||||
return 'Linux';
|
||||
}
|
||||
|
||||
if (ua.includes('Android')) {
|
||||
return 'Android';
|
||||
}
|
||||
|
||||
return platform || 'Unknown';
|
||||
};
|
||||
|
||||
const getDeviceType = (): string => {
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
if (ua.includes('Mobile')) {
|
||||
return 'Mobile';
|
||||
}
|
||||
|
||||
if (ua.includes('Tablet')) {
|
||||
return 'Tablet';
|
||||
}
|
||||
|
||||
return 'Desktop';
|
||||
};
|
||||
|
||||
// Get more detailed memory info if available
|
||||
const getMemoryInfo = (): string => {
|
||||
if ('memory' in performance) {
|
||||
const memory = (performance as any).memory;
|
||||
return `${formatBytes(memory.jsHeapSizeLimit)} (Used: ${formatBytes(memory.usedJSHeapSize)})`;
|
||||
}
|
||||
|
||||
return 'Not available';
|
||||
};
|
||||
|
||||
return {
|
||||
os: getOperatingSystem(),
|
||||
browser: getBrowserInfo(),
|
||||
screen: `${window.screen.width}x${window.screen.height}`,
|
||||
language: navigator.language,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
memory: getMemoryInfo(),
|
||||
cores: navigator.hardwareConcurrency || 0,
|
||||
deviceType: getDeviceType(),
|
||||
|
||||
// Add new fields
|
||||
colorDepth: `${window.screen.colorDepth}-bit`,
|
||||
pixelRatio: window.devicePixelRatio,
|
||||
online: navigator.onLine,
|
||||
cookiesEnabled: navigator.cookieEnabled,
|
||||
doNotTrack: navigator.doNotTrack === '1',
|
||||
};
|
||||
}
|
||||
|
||||
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<string[]>([]);
|
||||
const { providers, isLatestBranch } = 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 = await Promise.all(
|
||||
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] || provider.settings.baseUrl || null; // Ensure baseUrl is used
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setActiveProviders(statuses);
|
||||
} catch (error) {
|
||||
console.error('[Debug] Failed to update provider statuses:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setActiveProviders(
|
||||
Object.entries(providers)
|
||||
.filter(([_key, provider]) => provider.settings.enabled)
|
||||
.map(([_key, provider]) => provider.name),
|
||||
);
|
||||
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 branchToCheck = isLatestBranch ? 'main' : 'stable';
|
||||
console.log(`[Debug] Checking for updates against ${branchToCheck} branch`);
|
||||
|
||||
const localCommitResponse = await fetch(GITHUB_URLS.commitJson(branchToCheck));
|
||||
|
||||
if (!localCommitResponse.ok) {
|
||||
throw new Error('Failed to fetch local commit info');
|
||||
}
|
||||
|
||||
const localCommitData = (await localCommitResponse.json()) as CommitData;
|
||||
const remoteCommitHash = localCommitData.commit;
|
||||
const currentCommitHash = versionHash;
|
||||
|
||||
if (remoteCommitHash !== currentCommitHash) {
|
||||
setUpdateMessage(
|
||||
`Update available from ${branchToCheck} branch!\n` +
|
||||
`Current: ${currentCommitHash.slice(0, 7)}\n` +
|
||||
`Latest: ${remoteCommitHash.slice(0, 7)}`,
|
||||
);
|
||||
} else {
|
||||
setUpdateMessage(`You are on the latest version from the ${branchToCheck} branch`);
|
||||
}
|
||||
} catch (error) {
|
||||
setUpdateMessage('Failed to check for updates');
|
||||
console.error('[Debug] Failed to check for updates:', error);
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
}
|
||||
}, [isCheckingUpdate, isLatestBranch]);
|
||||
|
||||
const handleCopyToClipboard = useCallback(() => {
|
||||
const debugInfo = {
|
||||
OS: navigator.platform,
|
||||
Browser: navigator.userAgent,
|
||||
ActiveFeatures: activeProviders,
|
||||
BaseURLs: {
|
||||
Ollama: process.env.REACT_APP_OLLAMA_URL,
|
||||
OpenAI: process.env.REACT_APP_OPENAI_URL,
|
||||
LMStudio: process.env.REACT_APP_LM_STUDIO_URL,
|
||||
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: {
|
||||
hash: versionHash.slice(0, 7),
|
||||
branch: isLatestBranch ? 'main' : 'stable',
|
||||
},
|
||||
Version: versionHash,
|
||||
Timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
|
||||
alert('Debug information copied to clipboard!');
|
||||
toast.success('Debug information copied to clipboard!');
|
||||
});
|
||||
}, [providers]);
|
||||
}, [activeProviders, systemInfo, isLatestBranch]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Debug Tab</h3>
|
||||
<button
|
||||
onClick={handleCopyToClipboard}
|
||||
className="bg-blue-500 text-white rounded-lg px-4 py-2 hover:bg-blue-600 mb-4 transition-colors duration-200"
|
||||
>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary">System Information</h4>
|
||||
<p className="text-bolt-elements-textSecondary">OS: {navigator.platform}</p>
|
||||
<p className="text-bolt-elements-textSecondary">Browser: {navigator.userAgent}</p>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mt-4">Active Features</h4>
|
||||
<ul>
|
||||
{activeProviders.map((name) => (
|
||||
<li key={name} className="text-bolt-elements-textSecondary">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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">Device Type</p>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.deviceType}</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">Display</p>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Connection</p>
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${systemInfo.online ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
/>
|
||||
<span className={`${systemInfo.online ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{systemInfo.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</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">
|
||||
(v{versionTag || '0.0.1'}) - {isLatestBranch ? 'nightly' : 'stable'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mt-4">Base URLs</h4>
|
||||
<ul>
|
||||
<li className="text-bolt-elements-textSecondary">Ollama: {process.env.REACT_APP_OLLAMA_URL}</li>
|
||||
<li className="text-bolt-elements-textSecondary">OpenAI: {process.env.REACT_APP_OPENAI_URL}</li>
|
||||
<li className="text-bolt-elements-textSecondary">LM Studio: {process.env.REACT_APP_LM_STUDIO_URL}</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mt-4">Version Information</h4>
|
||||
<p className="text-bolt-elements-textSecondary">Version Hash: {versionHash}</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import { PromptLibrary } from '~/lib/common/prompt-library';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
|
||||
export default function FeaturesTab() {
|
||||
const { debug, enableDebugMode, isLocalModel, enableLocalModels } = useSettings();
|
||||
const {
|
||||
debug,
|
||||
enableDebugMode,
|
||||
isLocalModel,
|
||||
enableLocalModels,
|
||||
enableEventLogs,
|
||||
isLatestBranch,
|
||||
enableLatestBranch,
|
||||
promptId,
|
||||
setPromptId,
|
||||
} = useSettings();
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
enableDebugMode(enabled);
|
||||
enableEventLogs(enabled);
|
||||
};
|
||||
|
||||
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 className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-bolt-elements-textPrimary">Debug Features</span>
|
||||
<Switch className="ml-auto" checked={debug} onCheckedChange={handleToggle} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-bolt-elements-textPrimary">Use Main Branch</span>
|
||||
<p className="text-sm text-bolt-elements-textSecondary">
|
||||
Check for updates against the main branch instead of stable
|
||||
</p>
|
||||
</div>
|
||||
<Switch className="ml-auto" checked={isLatestBranch} onCheckedChange={enableLatestBranch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,10 +47,28 @@ export default function FeaturesTab() {
|
||||
<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>
|
||||
<span className="text-bolt-elements-textPrimary">Experimental Providers</span>
|
||||
<Switch className="ml-auto" checked={isLocalModel} onCheckedChange={enableLocalModels} />
|
||||
</div>
|
||||
<div className="flex items-start justify-between pt-4 mb-2 gap-2">
|
||||
<div className="flex-1 max-w-[200px]">
|
||||
<span className="text-bolt-elements-textPrimary">Prompt Library</span>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Choose a prompt from the library to use as the system prompt.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={promptId}
|
||||
onChange={(e) => setPromptId(e.target.value)}
|
||||
className="flex-1 p-2 ml-auto 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 text-sm min-w-[100px]"
|
||||
>
|
||||
{PromptLibrary.getList().map((x) => (
|
||||
<option value={x.id}>{x.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,10 @@ 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';
|
||||
|
||||
// Import a default fallback icon
|
||||
import DefaultIcon from '/icons/Default.svg'; // Adjust the path as necessary
|
||||
|
||||
export default function ProvidersTab() {
|
||||
const { providers, updateProviderSettings, isLocalModel } = useSettings();
|
||||
@@ -49,11 +53,30 @@ export default function ProvidersTab() {
|
||||
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">
|
||||
<span className="text-bolt-elements-textPrimary">{provider.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`/icons/${provider.name}.svg`} // Attempt to load the specific icon
|
||||
onError={(e) => {
|
||||
// Fallback to default icon on error
|
||||
e.currentTarget.src = DefaultIcon;
|
||||
}}
|
||||
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 })}
|
||||
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 */}
|
||||
@@ -63,9 +86,14 @@ export default function ProvidersTab() {
|
||||
<input
|
||||
type="text"
|
||||
value={provider.settings.baseUrl || ''}
|
||||
onChange={(e) =>
|
||||
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: e.target.value })
|
||||
}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,25 @@ const menuVariants = {
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
|
||||
function CurrentDateTime() {
|
||||
const [dateTime, setDateTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setDateTime(new Date());
|
||||
}, 60000); // Update every minute
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor">
|
||||
<div className="h-4 w-4 i-ph:clock-thin" />
|
||||
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Menu = () => {
|
||||
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -126,18 +145,17 @@ export const Menu = () => {
|
||||
variants={menuVariants}
|
||||
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
||||
>
|
||||
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
|
||||
<div className="h-[60px]" /> {/* Spacer for top margin */}
|
||||
<CurrentDateTime />
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4 select-none">
|
||||
<a
|
||||
href="/"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4"
|
||||
>
|
||||
<span className="inline-block i-bolt:chat scale-110" />
|
||||
Start new chat
|
||||
</a>
|
||||
</div>
|
||||
<div className="pl-4 pr-4 my-2">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, forwardRef, type ForwardedRef } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
|
||||
@@ -25,41 +25,48 @@ type IconButtonWithChildrenProps = {
|
||||
|
||||
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
|
||||
|
||||
// Componente IconButton com suporte a refs
|
||||
export const IconButton = memo(
|
||||
({
|
||||
icon,
|
||||
size = 'xl',
|
||||
className,
|
||||
iconClassName,
|
||||
disabledClassName,
|
||||
disabled = false,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: IconButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
|
||||
{
|
||||
[classNames('opacity-30', disabledClassName)]: disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
icon,
|
||||
size = 'xl',
|
||||
className,
|
||||
iconClassName,
|
||||
disabledClassName,
|
||||
disabled = false,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: IconButtonProps,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
|
||||
{
|
||||
[classNames('opacity-30', disabledClassName)]: disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick?.(event);
|
||||
}}
|
||||
>
|
||||
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
onClick?.(event);
|
||||
}}
|
||||
>
|
||||
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function getIconSize(size: IconSize) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { forwardRef, type ForwardedRef, type ReactElement } from 'react';
|
||||
|
||||
interface TooltipProps {
|
||||
tooltip: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
children: ReactElement;
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
arrowClassName?: string;
|
||||
@@ -12,62 +13,67 @@ interface TooltipProps {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const WithTooltip = ({
|
||||
tooltip,
|
||||
children,
|
||||
sideOffset = 5,
|
||||
className = '',
|
||||
arrowClassName = '',
|
||||
tooltipStyle = {},
|
||||
position = 'top',
|
||||
maxWidth = 250,
|
||||
delay = 0,
|
||||
}: TooltipProps) => {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={delay}>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side={position}
|
||||
className={`
|
||||
z-[2000]
|
||||
px-2.5
|
||||
py-1.5
|
||||
max-h-[300px]
|
||||
select-none
|
||||
rounded-md
|
||||
bg-bolt-elements-background-depth-3
|
||||
text-bolt-elements-textPrimary
|
||||
text-sm
|
||||
leading-tight
|
||||
shadow-lg
|
||||
animate-in
|
||||
fade-in-0
|
||||
zoom-in-95
|
||||
data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0
|
||||
data-[state=closed]:zoom-out-95
|
||||
${className}
|
||||
`}
|
||||
sideOffset={sideOffset}
|
||||
style={{
|
||||
maxWidth,
|
||||
...tooltipStyle,
|
||||
}}
|
||||
>
|
||||
<div className="break-words">{tooltip}</div>
|
||||
<Tooltip.Arrow
|
||||
const WithTooltip = forwardRef(
|
||||
(
|
||||
{
|
||||
tooltip,
|
||||
children,
|
||||
sideOffset = 5,
|
||||
className = '',
|
||||
arrowClassName = '',
|
||||
tooltipStyle = {},
|
||||
position = 'top',
|
||||
maxWidth = 250,
|
||||
delay = 0,
|
||||
}: TooltipProps,
|
||||
_ref: ForwardedRef<HTMLElement>,
|
||||
) => {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={delay}>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side={position}
|
||||
className={`
|
||||
fill-bolt-elements-background-depth-3
|
||||
${arrowClassName}
|
||||
z-[2000]
|
||||
px-2.5
|
||||
py-1.5
|
||||
max-h-[300px]
|
||||
select-none
|
||||
rounded-md
|
||||
bg-bolt-elements-background-depth-3
|
||||
text-bolt-elements-textPrimary
|
||||
text-sm
|
||||
leading-tight
|
||||
shadow-lg
|
||||
animate-in
|
||||
fade-in-0
|
||||
zoom-in-95
|
||||
data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0
|
||||
data-[state=closed]:zoom-out-95
|
||||
${className}
|
||||
`}
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
};
|
||||
sideOffset={sideOffset}
|
||||
style={{
|
||||
maxWidth,
|
||||
...tooltipStyle,
|
||||
}}
|
||||
>
|
||||
<div className="break-words">{tooltip}</div>
|
||||
<Tooltip.Arrow
|
||||
className={`
|
||||
fill-bolt-elements-background-depth-3
|
||||
${arrowClassName}
|
||||
`}
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default WithTooltip;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
|
||||
const logger = createScopedLogger('FileTree');
|
||||
|
||||
@@ -110,6 +111,22 @@ export const FileTree = memo(
|
||||
});
|
||||
};
|
||||
|
||||
const onCopyPath = (fileOrFolder: FileNode | FolderNode) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(fileOrFolder.fullPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(fileOrFolder.fullPath.substring((rootFolder || '').length));
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
@@ -121,6 +138,12 @@ export const FileTree = memo(
|
||||
selected={selectedFile === fileOrFolder.fullPath}
|
||||
file={fileOrFolder}
|
||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
||||
onCopyPath={() => {
|
||||
onCopyPath(fileOrFolder);
|
||||
}}
|
||||
onCopyRelativePath={() => {
|
||||
onCopyRelativePath(fileOrFolder);
|
||||
}}
|
||||
onClick={() => {
|
||||
onFileSelect?.(fileOrFolder.fullPath);
|
||||
}}
|
||||
@@ -134,6 +157,12 @@ export const FileTree = memo(
|
||||
folder={fileOrFolder}
|
||||
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
||||
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||
onCopyPath={() => {
|
||||
onCopyPath(fileOrFolder);
|
||||
}}
|
||||
onCopyRelativePath={() => {
|
||||
onCopyRelativePath(fileOrFolder);
|
||||
}}
|
||||
onClick={() => {
|
||||
toggleCollapseState(fileOrFolder.fullPath);
|
||||
}}
|
||||
@@ -156,26 +185,67 @@ interface FolderProps {
|
||||
folder: FolderNode;
|
||||
collapsed: boolean;
|
||||
selected?: boolean;
|
||||
onCopyPath: () => void;
|
||||
onCopyRelativePath: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
|
||||
interface FolderContextMenuProps {
|
||||
onCopyPath?: () => void;
|
||||
onCopyRelativePath?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames({
|
||||
'i-ph:caret-right scale-98': collapsed,
|
||||
'i-ph:caret-down scale-98': !collapsed,
|
||||
})}
|
||||
onClick={onClick}
|
||||
<ContextMenu.Item
|
||||
onSelect={onSelect}
|
||||
className="flex items-center gap-2 px-2 py-1.5 outline-0 text-sm text-bolt-elements-textPrimary cursor-pointer ws-nowrap text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive rounded-md"
|
||||
>
|
||||
{name}
|
||||
</NodeButton>
|
||||
<span className="size-4 shrink-0"></span>
|
||||
<span>{children}</span>
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content
|
||||
style={{ zIndex: 998 }}
|
||||
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
|
||||
>
|
||||
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
|
||||
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
|
||||
return (
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={folder.depth}
|
||||
iconClasses={classNames({
|
||||
'i-ph:caret-right scale-98': collapsed,
|
||||
'i-ph:caret-down scale-98': !collapsed,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{folder.name}
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,31 +253,43 @@ interface FileProps {
|
||||
file: FileNode;
|
||||
selected: boolean;
|
||||
unsavedChanges?: boolean;
|
||||
onCopyPath: () => void;
|
||||
onCopyRelativePath: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
|
||||
function File({
|
||||
file: { depth, name },
|
||||
onClick,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
selected,
|
||||
unsavedChanges = false,
|
||||
}: FileProps) {
|
||||
return (
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={classNames('flex items-center', {
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex-1 truncate pr-2">{name}</div>
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</NodeButton>
|
||||
<div
|
||||
className={classNames('flex items-center', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 truncate pr-2">{name}</div>
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PortDropdown } from './PortDropdown';
|
||||
import { ScreenshotSelector } from './ScreenshotSelector';
|
||||
|
||||
type ResizeSide = 'left' | 'right' | null;
|
||||
|
||||
@@ -20,6 +21,7 @@ export const Preview = memo(() => {
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
// Toggle between responsive mode and device mode
|
||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||
@@ -218,12 +220,17 @@ export const Preview = memo(() => {
|
||||
)}
|
||||
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||
|
||||
<IconButton
|
||||
icon="i-ph:selection"
|
||||
onClick={() => setIsSelectionMode(!isSelectionMode)}
|
||||
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
|
||||
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
||||
>
|
||||
<input
|
||||
title="URL"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent outline-none"
|
||||
type="text"
|
||||
@@ -281,7 +288,20 @@ export const Preview = memo(() => {
|
||||
}}
|
||||
>
|
||||
{activePreview ? (
|
||||
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
|
||||
<>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="preview"
|
||||
className="border-none w-full h-full bg-white"
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
/>
|
||||
<ScreenshotSelector
|
||||
isSelectionMode={isSelectionMode}
|
||||
setIsSelectionMode={setIsSelectionMode}
|
||||
containerRef={iframeRef}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
)}
|
||||
|
||||
293
app/components/workbench/ScreenshotSelector.tsx
Normal file
293
app/components/workbench/ScreenshotSelector.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ScreenshotSelectorProps {
|
||||
isSelectionMode: boolean;
|
||||
setIsSelectionMode: (mode: boolean) => void;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export const ScreenshotSelector = memo(
|
||||
({ isSelectionMode, setIsSelectionMode, containerRef }: ScreenshotSelectorProps) => {
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup function to stop all tracks when component unmounts
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.srcObject = null;
|
||||
videoRef.current.remove();
|
||||
videoRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initializeStream = async () => {
|
||||
if (!mediaStreamRef.current) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
displaySurface: 'window',
|
||||
preferCurrentTab: true,
|
||||
surfaceSwitching: 'include',
|
||||
systemAudio: 'exclude',
|
||||
},
|
||||
} as MediaStreamConstraints);
|
||||
|
||||
// Add handler for when sharing stops
|
||||
stream.addEventListener('inactive', () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.srcObject = null;
|
||||
videoRef.current.remove();
|
||||
videoRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
|
||||
setIsSelectionMode(false);
|
||||
setSelectionStart(null);
|
||||
setSelectionEnd(null);
|
||||
setIsCapturing(false);
|
||||
});
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// Initialize video element if needed
|
||||
if (!videoRef.current) {
|
||||
const video = document.createElement('video');
|
||||
video.style.opacity = '0';
|
||||
video.style.position = 'fixed';
|
||||
video.style.pointerEvents = 'none';
|
||||
video.style.zIndex = '-1';
|
||||
document.body.appendChild(video);
|
||||
videoRef.current = video;
|
||||
}
|
||||
|
||||
// Set up video with the stream
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize stream:', error);
|
||||
setIsSelectionMode(false);
|
||||
toast.error('Failed to initialize screen capture');
|
||||
}
|
||||
}
|
||||
|
||||
return mediaStreamRef.current;
|
||||
};
|
||||
|
||||
const handleCopySelection = useCallback(async () => {
|
||||
if (!isSelectionMode || !selectionStart || !selectionEnd || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCapturing(true);
|
||||
|
||||
try {
|
||||
const stream = await initializeStream();
|
||||
|
||||
if (!stream || !videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for video to be ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Create temporary canvas for full screenshot
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = videoRef.current.videoWidth;
|
||||
tempCanvas.height = videoRef.current.videoHeight;
|
||||
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (!tempCtx) {
|
||||
throw new Error('Failed to get temporary canvas context');
|
||||
}
|
||||
|
||||
// Draw the full video frame
|
||||
tempCtx.drawImage(videoRef.current, 0, 0);
|
||||
|
||||
// Calculate scale factor between video and screen
|
||||
const scaleX = videoRef.current.videoWidth / window.innerWidth;
|
||||
const scaleY = videoRef.current.videoHeight / window.innerHeight;
|
||||
|
||||
// Get window scroll position
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY + 40;
|
||||
|
||||
// Get the container's position in the page
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// Offset adjustments for more accurate clipping
|
||||
const leftOffset = -9; // Adjust left position
|
||||
const bottomOffset = -14; // Adjust bottom position
|
||||
|
||||
// Calculate the scaled coordinates with scroll offset and adjustments
|
||||
const scaledX = Math.round(
|
||||
(containerRect.left + Math.min(selectionStart.x, selectionEnd.x) + scrollX + leftOffset) * scaleX,
|
||||
);
|
||||
const scaledY = Math.round(
|
||||
(containerRect.top + Math.min(selectionStart.y, selectionEnd.y) + scrollY + bottomOffset) * scaleY,
|
||||
);
|
||||
const scaledWidth = Math.round(Math.abs(selectionEnd.x - selectionStart.x) * scaleX);
|
||||
const scaledHeight = Math.round(Math.abs(selectionEnd.y - selectionStart.y) * scaleY);
|
||||
|
||||
// Create final canvas for the cropped area
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(Math.abs(selectionEnd.x - selectionStart.x));
|
||||
canvas.height = Math.round(Math.abs(selectionEnd.y - selectionStart.y));
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
// Draw the cropped area
|
||||
ctx.drawImage(tempCanvas, scaledX, scaledY, scaledWidth, scaledHeight, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Convert to blob
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
// Create a FileReader to convert blob to base64
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
|
||||
// Find the textarea element
|
||||
const textarea = document.querySelector('textarea');
|
||||
|
||||
if (textarea) {
|
||||
// Get the setters from the BaseChat component
|
||||
const setUploadedFiles = (window as any).__BOLT_SET_UPLOADED_FILES__;
|
||||
const setImageDataList = (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
|
||||
const uploadedFiles = (window as any).__BOLT_UPLOADED_FILES__ || [];
|
||||
const imageDataList = (window as any).__BOLT_IMAGE_DATA_LIST__ || [];
|
||||
|
||||
if (setUploadedFiles && setImageDataList) {
|
||||
// Update the files and image data
|
||||
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
|
||||
setUploadedFiles([...uploadedFiles, file]);
|
||||
setImageDataList([...imageDataList, base64Image]);
|
||||
toast.success('Screenshot captured and added to chat');
|
||||
} else {
|
||||
toast.error('Could not add screenshot to chat');
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (error) {
|
||||
console.error('Failed to capture screenshot:', error);
|
||||
toast.error('Failed to capture screenshot');
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
setSelectionStart(null);
|
||||
setSelectionEnd(null);
|
||||
setIsSelectionMode(false); // Turn off selection mode after capture
|
||||
}
|
||||
}, [isSelectionMode, selectionStart, selectionEnd, containerRef, setIsSelectionMode]);
|
||||
|
||||
const handleSelectionStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isSelectionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
setSelectionStart({ x, y });
|
||||
setSelectionEnd({ x, y });
|
||||
},
|
||||
[isSelectionMode],
|
||||
);
|
||||
|
||||
const handleSelectionMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isSelectionMode || !selectionStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
setSelectionEnd({ x, y });
|
||||
},
|
||||
[isSelectionMode, selectionStart],
|
||||
);
|
||||
|
||||
if (!isSelectionMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
onMouseDown={handleSelectionStart}
|
||||
onMouseMove={handleSelectionMove}
|
||||
onMouseUp={handleCopySelection}
|
||||
onMouseLeave={() => {
|
||||
if (selectionStart) {
|
||||
setSelectionStart(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
pointerEvents: 'all',
|
||||
opacity: isCapturing ? 0 : 1,
|
||||
zIndex: 50,
|
||||
transition: 'opacity 0.1s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{selectionStart && selectionEnd && !isCapturing && (
|
||||
<div
|
||||
className="absolute border-2 border-blue-500 bg-blue-200 bg-opacity-20"
|
||||
style={{
|
||||
left: Math.min(selectionStart.x, selectionEnd.x),
|
||||
top: Math.min(selectionStart.y, selectionEnd.y),
|
||||
width: Math.abs(selectionEnd.x - selectionStart.x),
|
||||
height: Math.abs(selectionEnd.y - selectionStart.y),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user