Merge branch 'main' into add-loading-on-git-import-from-url
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>
|
||||
);
|
||||
|
||||
@@ -18,82 +18,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.RayContainer {
|
||||
--gradient-opacity: 0.85;
|
||||
--ray-gradient: radial-gradient(rgba(83, 196, 255, var(--gradient-opacity)) 0%, rgba(43, 166, 255, 0) 100%);
|
||||
transition: opacity 0.25s linear;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LightRayOne {
|
||||
width: 480px;
|
||||
height: 680px;
|
||||
transform: rotate(80deg);
|
||||
top: -540px;
|
||||
left: 250px;
|
||||
filter: blur(110px);
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayTwo {
|
||||
width: 110px;
|
||||
height: 400px;
|
||||
transform: rotate(-20deg);
|
||||
top: -280px;
|
||||
left: 350px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.6;
|
||||
filter: blur(60px);
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayThree {
|
||||
width: 400px;
|
||||
height: 370px;
|
||||
top: -350px;
|
||||
left: 200px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.6;
|
||||
filter: blur(21px);
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayFour {
|
||||
position: absolute;
|
||||
width: 330px;
|
||||
height: 370px;
|
||||
top: -330px;
|
||||
left: 50px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.5;
|
||||
filter: blur(21px);
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.LightRayFive {
|
||||
position: absolute;
|
||||
width: 110px;
|
||||
height: 400px;
|
||||
transform: rotate(-40deg);
|
||||
top: -280px;
|
||||
left: -10px;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.8;
|
||||
filter: blur(60px);
|
||||
border-radius: 100%;
|
||||
background: var(--ray-gradient);
|
||||
}
|
||||
|
||||
.PromptEffectContainer {
|
||||
--prompt-container-offset: 50px;
|
||||
--prompt-line-stroke-width: 1px;
|
||||
|
||||
@@ -17,7 +17,6 @@ import Cookies from 'js-cookie';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
import styles from './BaseChat.module.scss';
|
||||
import type { ProviderInfo } from '~/utils/types';
|
||||
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
|
||||
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
@@ -26,6 +25,9 @@ import GitCloneButton from './GitCloneButton';
|
||||
import FilePreview from './FilePreview';
|
||||
import { ModelSelector } from '~/components/chat/ModelSelector';
|
||||
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||
import type { IProviderSetting, ProviderInfo } from '~/types/model';
|
||||
import { ScreenshotStateManager } from './ScreenshotStateManager';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@@ -45,6 +47,7 @@ interface BaseChatProps {
|
||||
setModel?: (model: string) => void;
|
||||
provider?: ProviderInfo;
|
||||
setProvider?: (provider: ProviderInfo) => void;
|
||||
providerList?: ProviderInfo[];
|
||||
handleStop?: () => void;
|
||||
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
@@ -70,10 +73,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
setModel,
|
||||
provider,
|
||||
setProvider,
|
||||
providerList,
|
||||
input = '',
|
||||
enhancingPrompt,
|
||||
handleInputChange,
|
||||
promptEnhanced,
|
||||
|
||||
// promptEnhanced,
|
||||
enhancePrompt,
|
||||
sendMessage,
|
||||
handleStop,
|
||||
@@ -108,46 +113,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
|
||||
// Load enabled providers from cookies
|
||||
const [enabledProviders, setEnabledProviders] = useState(() => {
|
||||
const savedProviders = Cookies.get('providers');
|
||||
|
||||
if (savedProviders) {
|
||||
try {
|
||||
const parsedProviders = JSON.parse(savedProviders);
|
||||
return PROVIDER_LIST.filter((p) => parsedProviders[p.name]);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse providers from cookies:', error);
|
||||
return PROVIDER_LIST;
|
||||
}
|
||||
}
|
||||
|
||||
return PROVIDER_LIST;
|
||||
});
|
||||
|
||||
// Update enabled providers when cookies change
|
||||
useEffect(() => {
|
||||
const updateProvidersFromCookies = () => {
|
||||
const savedProviders = Cookies.get('providers');
|
||||
console.log(transcript);
|
||||
}, [transcript]);
|
||||
|
||||
if (savedProviders) {
|
||||
try {
|
||||
const parsedProviders = JSON.parse(savedProviders);
|
||||
setEnabledProviders(PROVIDER_LIST.filter((p) => parsedProviders[p.name]));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse providers from cookies:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateProvidersFromCookies();
|
||||
|
||||
const interval = setInterval(updateProvidersFromCookies, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [PROVIDER_LIST]);
|
||||
|
||||
console.log(transcript);
|
||||
useEffect(() => {
|
||||
// Load API keys from cookies on component mount
|
||||
try {
|
||||
@@ -167,7 +136,26 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
Cookies.remove('apiKeys');
|
||||
}
|
||||
|
||||
initializeModelList().then((modelList) => {
|
||||
let providerSettings: Record<string, IProviderSetting> | undefined = undefined;
|
||||
|
||||
try {
|
||||
const savedProviderSettings = Cookies.get('providers');
|
||||
|
||||
if (savedProviderSettings) {
|
||||
const parsedProviderSettings = JSON.parse(savedProviderSettings);
|
||||
|
||||
if (typeof parsedProviderSettings === 'object' && parsedProviderSettings !== null) {
|
||||
providerSettings = parsedProviderSettings;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading Provider Settings from cookies:', error);
|
||||
|
||||
// Clear invalid cookie data
|
||||
Cookies.remove('providers');
|
||||
}
|
||||
|
||||
initializeModelList(providerSettings).then((modelList) => {
|
||||
setModelList(modelList);
|
||||
});
|
||||
|
||||
@@ -291,24 +279,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
const baseChat = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.BaseChat,
|
||||
'relative flex flex-col lg:flex-row h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
|
||||
)}
|
||||
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<div className={classNames(styles.RayContainer)}>
|
||||
<div className={classNames(styles.LightRayOne)}></div>
|
||||
<div className={classNames(styles.LightRayTwo)}></div>
|
||||
<div className={classNames(styles.LightRayThree)}></div>
|
||||
<div className={classNames(styles.LightRayFour)}></div>
|
||||
<div className={classNames(styles.LightRayFive)}></div>
|
||||
</div>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[26vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
Where ideas begin
|
||||
</h1>
|
||||
@@ -353,15 +331,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(-45)"
|
||||
>
|
||||
<stop offset="0%" stopColor="#1488fc" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#1488fc" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#1488fc" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#1488fc" stopOpacity="0%"></stop>
|
||||
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine-gradient">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#8adaff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#8adaff" stopOpacity="80%"></stop>
|
||||
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
@@ -377,10 +355,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
modelList={modelList}
|
||||
provider={provider}
|
||||
setProvider={setProvider}
|
||||
providerList={PROVIDER_LIST}
|
||||
providerList={providerList || PROVIDER_LIST}
|
||||
apiKeys={apiKeys}
|
||||
/>
|
||||
{enabledProviders.length > 0 && provider && (
|
||||
{(providerList || []).length > 0 && provider && (
|
||||
<APIKeyManager
|
||||
provider={provider}
|
||||
apiKey={apiKeys[provider.name] || ''}
|
||||
@@ -401,6 +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',
|
||||
@@ -409,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',
|
||||
)}
|
||||
@@ -456,6 +444,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore if using input method engine
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
@@ -476,7 +469,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<SendButton
|
||||
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
||||
isStreaming={isStreaming}
|
||||
disabled={enabledProviders.length === 0}
|
||||
disabled={!providerList || providerList.length === 0}
|
||||
onClick={(event) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
@@ -498,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>
|
||||
|
||||
@@ -536,7 +520,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
!isModelSettingsCollapsed,
|
||||
})}
|
||||
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
|
||||
disabled={enabledProviders.length === 0}
|
||||
disabled={!providerList || providerList.length === 0}
|
||||
>
|
||||
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
|
||||
{isModelSettingsCollapsed ? <span className="text-xs">{model}</span> : <span />}
|
||||
|
||||
@@ -17,8 +17,9 @@ import { cubicEasingFn } from '~/utils/easings';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import { BaseChat } from './BaseChat';
|
||||
import Cookies from 'js-cookie';
|
||||
import type { ProviderInfo } from '~/utils/types';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -91,6 +92,8 @@ export const ChatImpl = memo(
|
||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||
const files = useStore(workbenchStore.files);
|
||||
const { activeProviders, promptId } = useSettings();
|
||||
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
@@ -111,14 +114,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,
|
||||
@@ -316,6 +330,7 @@ export const ChatImpl = memo(
|
||||
setModel={handleModelChange}
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
providerList={activeProviders}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -121,8 +86,8 @@ export const ModelSelector = ({
|
||||
>
|
||||
{[...modelList]
|
||||
.filter((e) => e.provider == provider?.name && e.name)
|
||||
.map((modelOption) => (
|
||||
<option key={modelOption.name} value={modelOption.name}>
|
||||
.map((modelOption, index) => (
|
||||
<option key={index} value={modelOption.name}>
|
||||
{modelOption.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
33
app/components/chat/ScreenshotStateManager.tsx
Normal file
33
app/components/chat/ScreenshotStateManager.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ScreenshotStateManagerProps {
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
uploadedFiles: File[];
|
||||
imageDataList: string[];
|
||||
}
|
||||
|
||||
export const ScreenshotStateManager = ({
|
||||
setUploadedFiles,
|
||||
setImageDataList,
|
||||
uploadedFiles,
|
||||
imageDataList,
|
||||
}: ScreenshotStateManagerProps) => {
|
||||
useEffect(() => {
|
||||
if (setUploadedFiles && setImageDataList) {
|
||||
(window as any).__BOLT_SET_UPLOADED_FILES__ = setUploadedFiles;
|
||||
(window as any).__BOLT_SET_IMAGE_DATA_LIST__ = setImageDataList;
|
||||
(window as any).__BOLT_UPLOADED_FILES__ = uploadedFiles;
|
||||
(window as any).__BOLT_IMAGE_DATA_LIST__ = imageDataList;
|
||||
}
|
||||
|
||||
return () => {
|
||||
delete (window as any).__BOLT_SET_UPLOADED_FILES__;
|
||||
delete (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
|
||||
delete (window as any).__BOLT_UPLOADED_FILES__;
|
||||
delete (window as any).__BOLT_IMAGE_DATA_LIST__;
|
||||
};
|
||||
}, [setUploadedFiles, setImageDataList, uploadedFiles, imageDataList]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { LanguageDescription } from '@codemirror/language';
|
||||
|
||||
export const supportedLanguages = [
|
||||
LanguageDescription.of({
|
||||
name: 'VUE',
|
||||
extensions: ['vue'],
|
||||
async load() {
|
||||
return import('@codemirror/lang-vue').then((module) => module.vue());
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'TS',
|
||||
extensions: ['ts'],
|
||||
|
||||
@@ -10,18 +10,17 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className={classNames(
|
||||
'flex items-center bg-bolt-elements-background-depth-1 p-5 border-b h-[var(--header-height)]',
|
||||
{
|
||||
'border-transparent': !chat.started,
|
||||
'border-bolt-elements-borderColor': chat.started,
|
||||
},
|
||||
)}
|
||||
className={classNames('flex items-center p-5 border-b h-[var(--header-height)]', {
|
||||
'border-transparent': !chat.started,
|
||||
'border-bolt-elements-borderColor': chat.started,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
|
||||
<div className="i-ph:sidebar-simple-duotone text-xl" />
|
||||
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
|
||||
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
||||
{/* <span className="i-bolt:logo-text?mask w-[46px] inline-block" /> */}
|
||||
<img src="/logo-light-styled.png" alt="logo" className="w-[90px] inline-block dark:hidden" />
|
||||
<img src="/logo-dark-styled.png" alt="logo" className="w-[90px] inline-block hidden dark:block" />
|
||||
</a>
|
||||
</div>
|
||||
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||
|
||||
63
app/components/settings/Settings.module.scss
Normal file
63
app/components/settings/Settings.module.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
.settings-tabs {
|
||||
button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&.active {
|
||||
background: var(--bolt-elements-button-primary-background);
|
||||
color: var(--bolt-elements-textPrimary);
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
background: var(--bolt-elements-bg-depth-3);
|
||||
color: var(--bolt-elements-textPrimary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bolt-elements-button-primary-backgroundHover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
background-color: var(--bolt-elements-button-primary-background);
|
||||
color: var(--bolt-elements-textPrimary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bolt-elements-button-primary-backgroundHover);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-danger-area {
|
||||
background-color: transparent;
|
||||
color: var(--bolt-elements-textPrimary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-style: solid;
|
||||
border-color: var(--bolt-elements-button-danger-backgroundHover);
|
||||
border-width: thin;
|
||||
|
||||
button {
|
||||
background-color: var(--bolt-elements-button-danger-background);
|
||||
color: var(--bolt-elements-button-danger-text);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bolt-elements-button-danger-backgroundHover);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/components/settings/SettingsWindow.tsx
Normal file
128
app/components/settings/SettingsWindow.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { DialogTitle, dialogVariants, dialogBackdropVariants } from '~/components/ui/Dialog';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import styles from './Settings.module.scss';
|
||||
import ChatHistoryTab from './chat-history/ChatHistoryTab';
|
||||
import ProvidersTab from './providers/ProvidersTab';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import FeaturesTab from './features/FeaturesTab';
|
||||
import DebugTab from './debug/DebugTab';
|
||||
import EventLogsTab from './event-logs/EventLogsTab';
|
||||
import ConnectionsTab from './connections/ConnectionsTab';
|
||||
|
||||
interface SettingsProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'chat-history' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
|
||||
|
||||
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
||||
const { debug, eventLogs } = useSettings();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('chat-history');
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: string; component?: ReactElement }[] = [
|
||||
{ id: 'chat-history', label: 'Chat History', icon: 'i-ph:book', component: <ChatHistoryTab /> },
|
||||
{ id: 'providers', label: 'Providers', icon: 'i-ph:key', component: <ProvidersTab /> },
|
||||
{ id: 'connection', label: 'Connection', icon: 'i-ph:link', component: <ConnectionsTab /> },
|
||||
{ id: 'features', label: 'Features', icon: 'i-ph:star', component: <FeaturesTab /> },
|
||||
...(debug
|
||||
? [
|
||||
{
|
||||
id: 'debug' as TabType,
|
||||
label: 'Debug Tab',
|
||||
icon: 'i-ph:bug',
|
||||
component: <DebugTab />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(eventLogs
|
||||
? [
|
||||
{
|
||||
id: 'event-logs' as TabType,
|
||||
label: 'Event Logs',
|
||||
icon: 'i-ph:list-bullets',
|
||||
component: <EventLogsTab />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay asChild onClick={onClose}>
|
||||
<motion.div
|
||||
className="bg-black/50 fixed inset-0 z-max backdrop-blur-sm"
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={dialogBackdropVariants}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className="fixed top-[50%] left-[50%] z-max h-[85vh] w-[90vw] max-w-[900px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg shadow-lg focus:outline-none overflow-hidden"
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={dialogVariants}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-48 border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1 p-4 flex flex-col justify-between',
|
||||
styles['settings-tabs'],
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="flex-shrink-0 text-lg font-semibold text-bolt-elements-textPrimary mb-2">
|
||||
Settings
|
||||
</DialogTitle>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={classNames(activeTab === tab.id ? styles.active : '')}
|
||||
>
|
||||
<div className={tab.icon} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<a
|
||||
href="https://github.com/stackblitz-labs/bolt.diy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
|
||||
>
|
||||
<div className="i-ph:github-logo" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://stackblitz-labs.github.io/bolt.diy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
|
||||
>
|
||||
<div className="i-ph:book" />
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-8 pt-10 bg-bolt-elements-background-depth-2">
|
||||
<div className="flex-1 overflow-y-auto">{tabs.find((tab) => tab.id === activeTab)?.component}</div>
|
||||
</div>
|
||||
</div>
|
||||
<RadixDialog.Close asChild onClick={onClose}>
|
||||
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
|
||||
</RadixDialog.Close>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
};
|
||||
119
app/components/settings/chat-history/ChatHistoryTab.tsx
Normal file
119
app/components/settings/chat-history/ChatHistoryTab.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { db, deleteById, getAll } from '~/lib/persistence';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import styles from '~/components/settings/Settings.module.scss';
|
||||
import { logStore } from '~/lib/stores/logs'; // Import logStore for event logging
|
||||
|
||||
export default function ChatHistoryTab() {
|
||||
const navigate = useNavigate();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const downloadAsJson = (data: any, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDeleteAllChats = async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
const allChats = await getAll(db);
|
||||
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
|
||||
logStore.logSystem('All chats deleted successfully', { count: allChats.length });
|
||||
toast.success('All chats deleted successfully');
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to delete chats', error);
|
||||
toast.error('Failed to delete chats');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAllChats = async () => {
|
||||
if (!db) {
|
||||
const error = new Error('Database is not available');
|
||||
logStore.logError('Failed to export chats - DB unavailable', error);
|
||||
toast.error('Database is not available');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allChats = await getAll(db);
|
||||
const exportData = {
|
||||
chats: allChats,
|
||||
exportDate: new Date().toISOString(),
|
||||
};
|
||||
|
||||
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
|
||||
logStore.logSystem('Chats exported successfully', { count: allChats.length });
|
||||
toast.success('Chats exported successfully');
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to export chats', error);
|
||||
toast.error('Failed to export chats');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Chat History</h3>
|
||||
<button
|
||||
onClick={handleExportAllChats}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-button-primary-background',
|
||||
'rounded-lg px-4 py-2 mb-4 transition-colors duration-200',
|
||||
'hover:bg-bolt-elements-button-primary-backgroundHover',
|
||||
'text-bolt-elements-button-primary-text',
|
||||
)}
|
||||
>
|
||||
Export All Chats
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={classNames('text-bolt-elements-textPrimary rounded-lg py-4 mb-4', styles['settings-danger-area'])}
|
||||
>
|
||||
<h4 className="font-semibold">Danger Area</h4>
|
||||
<p className="mb-2">This action cannot be undone!</p>
|
||||
<button
|
||||
onClick={handleDeleteAllChats}
|
||||
disabled={isDeleting}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-button-danger-background',
|
||||
'rounded-lg px-4 py-2 transition-colors duration-200',
|
||||
isDeleting ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bolt-elements-button-danger-backgroundHover',
|
||||
'text-bolt-elements-button-danger-text',
|
||||
)}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
app/components/settings/connections/ConnectionsTab.tsx
Normal file
54
app/components/settings/connections/ConnectionsTab.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Cookies from 'js-cookie';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
export default function ConnectionsTab() {
|
||||
const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || '');
|
||||
const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || '');
|
||||
|
||||
const handleSaveConnection = () => {
|
||||
Cookies.set('githubUsername', githubUsername);
|
||||
Cookies.set('githubToken', githubToken);
|
||||
logStore.logSystem('GitHub connection settings updated', {
|
||||
username: githubUsername,
|
||||
hasToken: !!githubToken,
|
||||
});
|
||||
toast.success('GitHub credentials saved successfully!');
|
||||
Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 mb-4 border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-3">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">GitHub Connection</h3>
|
||||
<div className="flex mb-4">
|
||||
<div className="flex-1 mr-2">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">GitHub Username:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={githubUsername}
|
||||
onChange={(e) => setGithubUsername(e.target.value)}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Personal Access Token:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
<button
|
||||
onClick={handleSaveConnection}
|
||||
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 mr-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
|
||||
>
|
||||
Save Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
620
app/components/settings/debug/DebugTab.tsx
Normal file
620
app/components/settings/debug/DebugTab.tsx
Normal file
@@ -0,0 +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';
|
||||
|
||||
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, 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(() => {
|
||||
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 = {
|
||||
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',
|
||||
},
|
||||
Timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
|
||||
toast.success('Debug information copied to clipboard!');
|
||||
});
|
||||
}, [activeProviders, systemInfo, isLatestBranch]);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Debug Information</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopyToClipboard}
|
||||
className="bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200 hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
|
||||
>
|
||||
Copy Debug Info
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckForUpdate}
|
||||
disabled={isCheckingUpdate}
|
||||
className={`bg-bolt-elements-button-primary-background rounded-lg px-4 py-2 transition-colors duration-200
|
||||
${!isCheckingUpdate ? 'hover:bg-bolt-elements-button-primary-backgroundHover' : 'opacity-75 cursor-not-allowed'}
|
||||
text-bolt-elements-button-primary-text`}
|
||||
>
|
||||
{isCheckingUpdate ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateMessage && (
|
||||
<div
|
||||
className={`bg-bolt-elements-surface rounded-lg p-3 ${
|
||||
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-400' : ''
|
||||
}`}
|
||||
>
|
||||
<p className="text-bolt-elements-textSecondary whitespace-pre-line">{updateMessage}</p>
|
||||
{updateMessage.includes('Update available') && (
|
||||
<div className="mt-3 text-sm">
|
||||
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
|
||||
<ol className="list-decimal ml-4 mt-1 text-bolt-elements-textSecondary">
|
||||
<li>
|
||||
Pull the latest changes:{' '}
|
||||
<code className="bg-bolt-elements-surface-hover px-1 rounded">git pull upstream main</code>
|
||||
</li>
|
||||
<li>
|
||||
Install any new dependencies:{' '}
|
||||
<code className="bg-bolt-elements-surface-hover px-1 rounded">pnpm install</code>
|
||||
</li>
|
||||
<li>Restart the application</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">System Information</h4>
|
||||
<div className="bg-bolt-elements-surface rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">Operating System</p>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.os}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bolt-elements-textSecondary">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>
|
||||
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">Local LLM Status</h4>
|
||||
<div className="bg-bolt-elements-surface rounded-lg">
|
||||
<div className="grid grid-cols-1 divide-y">
|
||||
{activeProviders.map((provider) => (
|
||||
<div key={provider.name} className="p-3 flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
!provider.enabled ? 'bg-gray-300' : provider.isRunning ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">{provider.name}</p>
|
||||
{provider.url && (
|
||||
<p className="text-xs text-bolt-elements-textSecondary truncate max-w-[300px]">
|
||||
{provider.url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
provider.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{provider.enabled && (
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
provider.isRunning ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{provider.isRunning ? 'Running' : 'Not Running'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-5 flex flex-col space-y-1 text-xs">
|
||||
{/* Status Details */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
|
||||
</span>
|
||||
{provider.responseTime && (
|
||||
<span className="text-bolt-elements-textSecondary">
|
||||
Response time: {Math.round(provider.responseTime)}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{provider.error && (
|
||||
<div className="mt-1 text-red-600 bg-red-50 rounded-md p-2">
|
||||
<span className="font-medium">Error:</span> {provider.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection Info */}
|
||||
{provider.url && (
|
||||
<div className="text-bolt-elements-textSecondary">
|
||||
<span className="font-medium">Endpoints checked:</span>
|
||||
<ul className="list-disc list-inside pl-2 mt-1">
|
||||
<li>{provider.url} (root)</li>
|
||||
<li>{provider.url}/api/health</li>
|
||||
<li>{provider.url}/v1/models</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeProviders.length === 0 && (
|
||||
<div className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
app/components/settings/event-logs/EventLogsTab.tsx
Normal file
219
app/components/settings/event-logs/EventLogsTab.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
export default function EventLogsTab() {
|
||||
const {} = useSettings();
|
||||
const showLogs = useStore(logStore.showLogs);
|
||||
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
const logs = logStore.getLogs();
|
||||
return logs.filter((log) => {
|
||||
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
log.message?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
JSON.stringify(log.details)?.toLowerCase()?.includes(searchQuery?.toLowerCase());
|
||||
|
||||
return matchesLevel && matchesSearch;
|
||||
});
|
||||
}, [logLevel, searchQuery]);
|
||||
|
||||
// Effect to initialize showLogs
|
||||
useEffect(() => {
|
||||
logStore.showLogs.set(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// System info logs
|
||||
logStore.logSystem('Application initialized', {
|
||||
version: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
|
||||
// Debug logs for system state
|
||||
logStore.logDebug('System configuration loaded', {
|
||||
runtime: 'Next.js',
|
||||
features: ['AI Chat', 'Event Logging'],
|
||||
});
|
||||
|
||||
// Warning logs for potential issues
|
||||
logStore.logWarning('Resource usage threshold approaching', {
|
||||
memoryUsage: '75%',
|
||||
cpuLoad: '60%',
|
||||
});
|
||||
|
||||
// Error logs with detailed context
|
||||
logStore.logError('API connection failed', new Error('Connection timeout'), {
|
||||
endpoint: '/api/chat',
|
||||
retryCount: 3,
|
||||
lastAttempt: new Date().toISOString(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.querySelector('.logs-container');
|
||||
|
||||
if (container && autoScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
const handleClearLogs = useCallback(() => {
|
||||
if (confirm('Are you sure you want to clear all logs?')) {
|
||||
logStore.clearLogs();
|
||||
toast.success('Logs cleared successfully');
|
||||
forceUpdate({}); // Force a re-render after clearing logs
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleExportLogs = useCallback(() => {
|
||||
try {
|
||||
const logText = logStore
|
||||
.getLogs()
|
||||
.map(
|
||||
(log) =>
|
||||
`[${log.level.toUpperCase()}] ${log.timestamp} - ${log.message}${
|
||||
log.details ? '\nDetails: ' + JSON.stringify(log.details, null, 2) : ''
|
||||
}`,
|
||||
)
|
||||
.join('\n\n');
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `event-logs-${new Date().toISOString()}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Logs exported successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export logs');
|
||||
console.error('Export error:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getLevelColor = (level: LogEntry['level']) => {
|
||||
switch (level) {
|
||||
case 'info':
|
||||
return 'text-blue-500';
|
||||
case 'warning':
|
||||
return 'text-yellow-500';
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
case 'debug':
|
||||
return 'text-gray-500';
|
||||
default:
|
||||
return 'text-bolt-elements-textPrimary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 h-full flex flex-col">
|
||||
<div className="flex flex-col space-y-4 mb-4">
|
||||
{/* Title and Toggles Row */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
|
||||
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
|
||||
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={logLevel}
|
||||
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
|
||||
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[20%] text-sm min-w-[100px]"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
{showLogs && (
|
||||
<div className="flex items-center gap-2 flex-nowrap">
|
||||
<button
|
||||
onClick={handleExportLogs}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-button-primary-background',
|
||||
'rounded-lg px-4 py-2 transition-colors duration-200',
|
||||
'hover:bg-bolt-elements-button-primary-backgroundHover',
|
||||
'text-bolt-elements-button-primary-text',
|
||||
)}
|
||||
>
|
||||
Export Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearLogs}
|
||||
className={classNames(
|
||||
'bg-bolt-elements-button-danger-background',
|
||||
'rounded-lg px-4 py-2 transition-colors duration-200',
|
||||
'hover:bg-bolt-elements-button-danger-backgroundHover',
|
||||
'text-bolt-elements-button-danger-text',
|
||||
)}
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-bolt-elements-bg-depth-1 rounded-lg p-4 h-[calc(100vh - 250px)] min-h-[400px] overflow-y-auto logs-container overflow-y-auto">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="text-center text-bolt-elements-textSecondary py-8">No logs found</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm mb-3 font-mono border-b border-bolt-elements-borderColor pb-2 last:border-0"
|
||||
>
|
||||
<div className="flex items-start space-x-2 flex-wrap">
|
||||
<span className={`font-bold ${getLevelColor(log.level)} whitespace-nowrap`}>
|
||||
[{log.level.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-bolt-elements-textSecondary whitespace-nowrap">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
|
||||
</div>
|
||||
{log.details && (
|
||||
<pre className="mt-2 text-xs text-bolt-elements-textSecondary overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/components/settings/features/FeaturesTab.tsx
Normal file
75
app/components/settings/features/FeaturesTab.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
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="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>
|
||||
|
||||
<div className="mb-6 border-t border-bolt-elements-borderColor pt-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Experimental Features</h3>
|
||||
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
||||
Disclaimer: Experimental features may be unstable and are subject to change.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-bolt-elements-textPrimary">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>
|
||||
);
|
||||
}
|
||||
106
app/components/settings/providers/ProvidersTab.tsx
Normal file
106
app/components/settings/providers/ProvidersTab.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import { useSettings } from '~/lib/hooks/useSettings';
|
||||
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
||||
import type { IProviderConfig } from '~/types/model';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
|
||||
// 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();
|
||||
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
||||
|
||||
// Load base URLs from cookies
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
|
||||
...value,
|
||||
name: key,
|
||||
}));
|
||||
|
||||
if (searchTerm && searchTerm.length > 0) {
|
||||
newFilteredProviders = newFilteredProviders.filter((provider) =>
|
||||
provider.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLocalModel) {
|
||||
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
|
||||
}
|
||||
|
||||
newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setFilteredProviders(newFilteredProviders);
|
||||
}, [providers, searchTerm, isLocalModel]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search providers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
{filteredProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className="flex flex-col mb-2 provider-item hover:bg-bolt-elements-bg-depth-3 p-4 rounded-lg border border-bolt-elements-borderColor "
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`/icons/${provider.name}.svg`} // 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 });
|
||||
|
||||
if (enabled) {
|
||||
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
||||
} else {
|
||||
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Base URL input for configurable providers */}
|
||||
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.settings.enabled && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Base URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={provider.settings.baseUrl || ''}
|
||||
onChange={(e) => {
|
||||
const newBaseUrl = e.target.value;
|
||||
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
||||
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
||||
provider: provider.name,
|
||||
baseUrl: newBaseUrl,
|
||||
});
|
||||
}}
|
||||
placeholder={`Enter ${provider.name} base URL`}
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
||||
import { Settings } from '~/components/ui/Settings';
|
||||
import { SettingsWindow } from '~/components/settings/SettingsWindow';
|
||||
import { SettingsButton } from '~/components/ui/SettingsButton';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
@@ -35,6 +35,25 @@ const menuVariants = {
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
|
||||
function CurrentDateTime() {
|
||||
const [dateTime, setDateTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setDateTime(new Date());
|
||||
}, 60000); // Update every minute
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor">
|
||||
<div className="h-4 w-4 i-ph:clock-thin" />
|
||||
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Menu = () => {
|
||||
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -126,18 +145,17 @@ export const Menu = () => {
|
||||
variants={menuVariants}
|
||||
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
||||
>
|
||||
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
|
||||
<div className="h-[60px]" /> {/* Spacer for top margin */}
|
||||
<CurrentDateTime />
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4 select-none">
|
||||
<a
|
||||
href="/"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4"
|
||||
>
|
||||
<span className="inline-block i-bolt:chat scale-110" />
|
||||
Start new chat
|
||||
</a>
|
||||
</div>
|
||||
<div className="pl-4 pr-4 my-2">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
||||
@@ -208,7 +226,7 @@ export const Menu = () => {
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
<Settings open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
|
||||
<SettingsWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
18
app/components/ui/BackgroundRays/index.tsx
Normal file
18
app/components/ui/BackgroundRays/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const BackgroundRays = () => {
|
||||
return (
|
||||
<div className={`${styles.rayContainer} `}>
|
||||
<div className={`${styles.lightRay} ${styles.ray1}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray2}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray3}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray4}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray5}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray6}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray7}`}></div>
|
||||
<div className={`${styles.lightRay} ${styles.ray8}`}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackgroundRays;
|
||||
246
app/components/ui/BackgroundRays/styles.module.scss
Normal file
246
app/components/ui/BackgroundRays/styles.module.scss
Normal file
@@ -0,0 +1,246 @@
|
||||
.rayContainer {
|
||||
// Theme-specific colors
|
||||
--ray-color-primary: color-mix(in srgb, var(--primary-color), transparent 30%);
|
||||
--ray-color-secondary: color-mix(in srgb, var(--secondary-color), transparent 30%);
|
||||
--ray-color-accent: color-mix(in srgb, var(--accent-color), transparent 30%);
|
||||
|
||||
// Theme-specific gradients
|
||||
--ray-gradient-primary: radial-gradient(var(--ray-color-primary) 0%, transparent 70%);
|
||||
--ray-gradient-secondary: radial-gradient(var(--ray-color-secondary) 0%, transparent 70%);
|
||||
--ray-gradient-accent: radial-gradient(var(--ray-color-accent) 0%, transparent 70%);
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 1.5s ease-out;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
// background-color: transparent;
|
||||
|
||||
:global(html[data-theme='dark']) & {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
:global(html[data-theme='light']) & {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
}
|
||||
|
||||
.lightRay {
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
|
||||
:global(html[data-theme='dark']) & {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
:global(html[data-theme='light']) & {
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.ray1 {
|
||||
width: 600px;
|
||||
height: 800px;
|
||||
background: var(--ray-gradient-primary);
|
||||
transform: rotate(65deg);
|
||||
top: -500px;
|
||||
left: -100px;
|
||||
filter: blur(80px);
|
||||
opacity: 0.6;
|
||||
animation: float1 15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray2 {
|
||||
width: 400px;
|
||||
height: 600px;
|
||||
background: var(--ray-gradient-secondary);
|
||||
transform: rotate(-30deg);
|
||||
top: -300px;
|
||||
left: 200px;
|
||||
filter: blur(60px);
|
||||
opacity: 0.6;
|
||||
animation: float2 18s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray3 {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
background: var(--ray-gradient-accent);
|
||||
top: -320px;
|
||||
left: 500px;
|
||||
filter: blur(65px);
|
||||
opacity: 0.5;
|
||||
animation: float3 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray4 {
|
||||
width: 400px;
|
||||
height: 450px;
|
||||
background: var(--ray-gradient-secondary);
|
||||
top: -350px;
|
||||
left: 800px;
|
||||
filter: blur(55px);
|
||||
opacity: 0.55;
|
||||
animation: float4 17s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray5 {
|
||||
width: 350px;
|
||||
height: 500px;
|
||||
background: var(--ray-gradient-primary);
|
||||
transform: rotate(-45deg);
|
||||
top: -250px;
|
||||
left: 1000px;
|
||||
filter: blur(45px);
|
||||
opacity: 0.6;
|
||||
animation: float5 16s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray6 {
|
||||
width: 300px;
|
||||
height: 700px;
|
||||
background: var(--ray-gradient-accent);
|
||||
transform: rotate(75deg);
|
||||
top: -400px;
|
||||
left: 600px;
|
||||
filter: blur(75px);
|
||||
opacity: 0.45;
|
||||
animation: float6 19s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray7 {
|
||||
width: 450px;
|
||||
height: 600px;
|
||||
background: var(--ray-gradient-primary);
|
||||
transform: rotate(45deg);
|
||||
top: -450px;
|
||||
left: 350px;
|
||||
filter: blur(65px);
|
||||
opacity: 0.55;
|
||||
animation: float7 21s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ray8 {
|
||||
width: 380px;
|
||||
height: 550px;
|
||||
background: var(--ray-gradient-secondary);
|
||||
transform: rotate(-60deg);
|
||||
top: -380px;
|
||||
left: 750px;
|
||||
filter: blur(58px);
|
||||
opacity: 0.6;
|
||||
animation: float8 14s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes float1 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(65deg) translate(0, 0);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(70deg) translate(30px, 20px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(60deg) translate(-20px, 40px);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(68deg) translate(-40px, 10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-30deg) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: rotate(-25deg) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: rotate(-35deg) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(40px, 20px) rotate(5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-30px, 40px) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float4 {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15) rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float5 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-45deg) translate(0, 0);
|
||||
}
|
||||
33% {
|
||||
transform: rotate(-40deg) translate(25px, -20px);
|
||||
}
|
||||
66% {
|
||||
transform: rotate(-50deg) translate(-25px, 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float6 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(75deg) scale(1);
|
||||
filter: blur(75px);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(85deg) scale(1.1);
|
||||
filter: blur(65px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float7 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(45deg) translate(0, 0);
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(40deg) translate(-30px, 30px);
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float8 {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-60deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-55deg) scale(1.05);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-65deg) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,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,444 +0,0 @@
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { DialogTitle, dialogVariants, dialogBackdropVariants } from './Dialog';
|
||||
import { IconButton } from './IconButton';
|
||||
import { providersList } from '~/lib/stores/settings';
|
||||
import { db, getAll, deleteById } from '~/lib/persistence';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useNavigate } from '@remix-run/react';
|
||||
import commit from '~/commit.json';
|
||||
import Cookies from 'js-cookie';
|
||||
import '~/styles/components/SettingsSlider.scss';
|
||||
import '~/styles/components/Settings.scss';
|
||||
|
||||
interface SettingsProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'chat-history' | 'providers' | 'features' | 'debug';
|
||||
|
||||
// Providers that support base URL configuration
|
||||
const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
|
||||
|
||||
export const Settings = ({ open, onClose }: SettingsProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('chat-history');
|
||||
const [isDebugEnabled, setIsDebugEnabled] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isJustSayEnabled, setIsJustSayEnabled] = useState(false);
|
||||
|
||||
// Load base URLs from cookies
|
||||
const [baseUrls, setBaseUrls] = useState(() => {
|
||||
const savedUrls = Cookies.get('providerBaseUrls');
|
||||
|
||||
if (savedUrls) {
|
||||
try {
|
||||
return JSON.parse(savedUrls);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse base URLs from cookies:', error);
|
||||
return {
|
||||
Ollama: 'http://localhost:11434',
|
||||
LMStudio: 'http://localhost:1234',
|
||||
OpenAILike: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Ollama: 'http://localhost:11434',
|
||||
LMStudio: 'http://localhost:1234',
|
||||
OpenAILike: '',
|
||||
};
|
||||
});
|
||||
|
||||
const handleBaseUrlChange = (provider: string, url: string) => {
|
||||
setBaseUrls((prev: Record<string, string>) => {
|
||||
const newUrls = { ...prev, [provider]: url };
|
||||
Cookies.set('providerBaseUrls', JSON.stringify(newUrls));
|
||||
|
||||
return newUrls;
|
||||
});
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string; icon: string }[] = [
|
||||
{ id: 'chat-history', label: 'Chat History', icon: 'i-ph:book' },
|
||||
{ id: 'providers', label: 'Providers', icon: 'i-ph:key' },
|
||||
{ id: 'features', label: 'Features', icon: 'i-ph:star' },
|
||||
...(isDebugEnabled ? [{ id: 'debug' as TabType, label: 'Debug Tab', icon: 'i-ph:bug' }] : []),
|
||||
];
|
||||
|
||||
// Load providers from cookies on mount
|
||||
const [providers, setProviders] = useState(() => {
|
||||
const savedProviders = Cookies.get('providers');
|
||||
|
||||
if (savedProviders) {
|
||||
try {
|
||||
const parsedProviders = JSON.parse(savedProviders);
|
||||
|
||||
// Merge saved enabled states with the base provider list
|
||||
return providersList.map((provider) => ({
|
||||
...provider,
|
||||
isEnabled: parsedProviders[provider.name] || false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse providers from cookies:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return providersList;
|
||||
});
|
||||
|
||||
const handleToggleProvider = (providerName: string) => {
|
||||
setProviders((prevProviders) => {
|
||||
const newProviders = prevProviders.map((provider) =>
|
||||
provider.name === providerName ? { ...provider, isEnabled: !provider.isEnabled } : provider,
|
||||
);
|
||||
|
||||
// Save to cookies
|
||||
const enabledStates = newProviders.reduce(
|
||||
(acc, provider) => ({
|
||||
...acc,
|
||||
[provider.name]: provider.isEnabled,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
Cookies.set('providers', JSON.stringify(enabledStates));
|
||||
|
||||
return newProviders;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredProviders = providers
|
||||
.filter((provider) => provider.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
const debugInfo = {
|
||||
OS: navigator.platform,
|
||||
Browser: navigator.userAgent,
|
||||
ActiveFeatures: providers.filter((provider) => provider.isEnabled).map((provider) => provider.name),
|
||||
BaseURLs: {
|
||||
Ollama: process.env.REACT_APP_OLLAMA_URL,
|
||||
OpenAI: process.env.REACT_APP_OPENAI_URL,
|
||||
LMStudio: process.env.REACT_APP_LM_STUDIO_URL,
|
||||
},
|
||||
Version: versionHash,
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)).then(() => {
|
||||
alert('Debug information copied to clipboard!');
|
||||
});
|
||||
};
|
||||
|
||||
const downloadAsJson = (data: any, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDeleteAllChats = async () => {
|
||||
if (!db) {
|
||||
toast.error('Database is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
const allChats = await getAll(db);
|
||||
|
||||
// Delete all chats one by one
|
||||
await Promise.all(allChats.map((chat) => deleteById(db!, chat.id)));
|
||||
|
||||
toast.success('All chats deleted successfully');
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete chats');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAllChats = async () => {
|
||||
if (!db) {
|
||||
toast.error('Database is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allChats = await getAll(db);
|
||||
const exportData = {
|
||||
chats: allChats,
|
||||
exportDate: new Date().toISOString(),
|
||||
};
|
||||
|
||||
downloadAsJson(exportData, `all-chats-${new Date().toISOString()}.json`);
|
||||
toast.success('Chats exported successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export chats');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const versionHash = commit.commit; // Get the version hash from commit.json
|
||||
|
||||
return (
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="bg-black/50 fixed inset-0 z-max"
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={dialogBackdropVariants}
|
||||
/>
|
||||
</RadixDialog.Overlay>
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className="fixed top-[50%] left-[50%] z-max h-[85vh] w-[90vw] max-w-[900px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg shadow-lg focus:outline-none overflow-hidden"
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={dialogVariants}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
<div className="w-48 border-r border-bolt-elements-borderColor bg-white dark:bg-gray-900 p-4 flex flex-col justify-between settings-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={classNames(activeTab === tab.id ? 'active' : '')}
|
||||
>
|
||||
<div className={tab.icon} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<a
|
||||
href="https://github.com/coleam00/bolt.new-any-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="settings-button flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:github-logo" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://coleam00.github.io/bolt.new-any-llm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="settings-button flex items-center gap-2"
|
||||
>
|
||||
<div className="i-ph:book" />
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-8 bg-gray-50 dark:bg-gray-800">
|
||||
<DialogTitle className="flex-shrink-0 text-lg font-semibold text-bolt-elements-textPrimary">
|
||||
Settings
|
||||
</DialogTitle>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'chat-history' && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Chat History</h3>
|
||||
<button
|
||||
onClick={handleExportAllChats}
|
||||
className="bg-blue-500 text-white rounded-lg px-4 py-2 hover:bg-blue-600 mb-4 transition-colors duration-200"
|
||||
>
|
||||
Export All Chats
|
||||
</button>
|
||||
|
||||
<div className="text-bolt-elements-textPrimary rounded-lg p-4 mb-4 settings-danger-area">
|
||||
<h4 className="font-semibold">Danger Area</h4>
|
||||
<p className="mb-2">This action cannot be undone!</p>
|
||||
<button
|
||||
onClick={handleDeleteAllChats}
|
||||
disabled={isDeleting}
|
||||
className={classNames(
|
||||
'bg-red-700 text-white rounded-lg px-4 py-2 transition-colors duration-200',
|
||||
isDeleting ? 'opacity-50 cursor-not-allowed' : 'hover:bg-red-800',
|
||||
)}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'providers' && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Providers</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search providers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="mb-4 p-2 rounded border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
{filteredProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className="flex flex-col mb-6 provider-item hover:bg-bolt-elements-bg-depth-3 p-4 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-bolt-elements-textPrimary">{provider.name}</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={provider.isEnabled}
|
||||
onChange={() => handleToggleProvider(provider.name)}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'settings-toggle__track',
|
||||
provider.isEnabled
|
||||
? 'settings-toggle__track--enabled'
|
||||
: 'settings-toggle__track--disabled',
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={classNames(
|
||||
'settings-toggle__thumb',
|
||||
provider.isEnabled ? 'settings-toggle__thumb--enabled' : '',
|
||||
)}
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
{/* Base URL input for configurable providers */}
|
||||
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && provider.isEnabled && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Base URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrls[provider.name]}
|
||||
onChange={(e) => handleBaseUrlChange(provider.name, e.target.value)}
|
||||
placeholder={`Enter ${provider.name} base URL`}
|
||||
className="w-full p-2 rounded border border-bolt-elements-borderColor bg-bolt-elements-bg-depth-2 text-bolt-elements-textPrimary text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'features' && (
|
||||
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg mb-4">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Feature Settings</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-bolt-elements-textPrimary">Debug Info</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={isDebugEnabled}
|
||||
onChange={() => setIsDebugEnabled(!isDebugEnabled)}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'settings-toggle__track',
|
||||
isDebugEnabled ? 'settings-toggle__track--enabled' : 'settings-toggle__track--disabled',
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={classNames(
|
||||
'settings-toggle__thumb',
|
||||
isDebugEnabled ? 'settings-toggle__thumb--enabled' : '',
|
||||
)}
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'features' && (
|
||||
<div className="p-4 bg-bolt-elements-bg-depth-2 border border-bolt-elements-borderColor rounded-lg">
|
||||
<h3 className="text-lg font-medium text-bolt-elements-textPrimary mb-4">Experimental Area</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-bolt-elements-textPrimary">Replace with local models</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={isJustSayEnabled}
|
||||
onChange={() => setIsJustSayEnabled(!isJustSayEnabled)}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'settings-toggle__track',
|
||||
isJustSayEnabled ? 'settings-toggle__track--enabled' : 'settings-toggle__track--disabled',
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={classNames(
|
||||
'settings-toggle__thumb',
|
||||
isJustSayEnabled ? 'settings-toggle__thumb--enabled' : '',
|
||||
)}
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'debug' && isDebugEnabled && (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<h4 className="text-md font-medium text-bolt-elements-textPrimary mt-4">Active Features</h4>
|
||||
<ul>
|
||||
{providers
|
||||
.filter((provider) => provider.isEnabled)
|
||||
.map((provider) => (
|
||||
<li key={provider.name} className="text-bolt-elements-textSecondary">
|
||||
{provider.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RadixDialog.Close asChild onClick={onClose}>
|
||||
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
|
||||
</RadixDialog.Close>
|
||||
</motion.div>
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
interface SettingsButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { memo } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import '~/styles/components/SettingsSlider.scss';
|
||||
|
||||
interface SliderOption<T> {
|
||||
value: T;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SliderOptions<T> {
|
||||
left: SliderOption<T>;
|
||||
right: SliderOption<T>;
|
||||
}
|
||||
|
||||
interface SettingsSliderProps<T> {
|
||||
selected: T;
|
||||
options: SliderOptions<T>;
|
||||
setSelected?: (selected: T) => void;
|
||||
}
|
||||
|
||||
export const SettingsSlider = memo(<T,>({ selected, options, setSelected }: SettingsSliderProps<T>) => {
|
||||
const isLeftSelected = selected === options.left.value;
|
||||
|
||||
return (
|
||||
<div className="settings-slider">
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'settings-slider__thumb',
|
||||
isLeftSelected ? 'settings-slider__thumb--left' : 'settings-slider__thumb--right',
|
||||
)}
|
||||
initial={false}
|
||||
animate={{
|
||||
x: isLeftSelected ? 0 : '100%',
|
||||
opacity: 0.2,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSelected?.(options.left.value)}
|
||||
className={classNames(
|
||||
'settings-slider__button',
|
||||
isLeftSelected ? 'settings-slider__button--selected' : 'settings-slider__button--unselected',
|
||||
)}
|
||||
>
|
||||
{options.left.text}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected?.(options.right.value)}
|
||||
className={classNames(
|
||||
'settings-slider__button',
|
||||
!isLeftSelected ? 'settings-slider__button--selected' : 'settings-slider__button--unselected',
|
||||
)}
|
||||
>
|
||||
{options.right.text}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
37
app/components/ui/Switch.tsx
Normal file
37
app/components/ui/Switch.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { memo } from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface SwitchProps {
|
||||
className?: string;
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (event: boolean) => void;
|
||||
}
|
||||
|
||||
export const Switch = memo(({ className, onCheckedChange, checked }: SwitchProps) => {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={classNames(
|
||||
'relative h-6 w-11 cursor-pointer rounded-full bg-bolt-elements-button-primary-background',
|
||||
'transition-colors duration-200 ease-in-out',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-bolt-elements-item-contentAccent',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
onCheckedChange={(e) => onCheckedChange?.(e)}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={classNames(
|
||||
'block h-5 w-5 rounded-full bg-white',
|
||||
'shadow-lg shadow-black/20',
|
||||
'transition-transform duration-200 ease-in-out',
|
||||
'translate-x-0.5',
|
||||
'data-[state=checked]:translate-x-[1.375rem]',
|
||||
'will-change-transform',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -17,6 +17,7 @@ import { renderLogger } from '~/utils/logger';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@@ -180,21 +181,22 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
return;
|
||||
}
|
||||
|
||||
const githubUsername = prompt('Please enter your GitHub username:');
|
||||
const githubUsername = Cookies.get('githubUsername');
|
||||
const githubToken = Cookies.get('githubToken');
|
||||
|
||||
if (!githubUsername) {
|
||||
alert('GitHub username is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
if (!githubUsername || !githubToken) {
|
||||
const usernameInput = prompt('Please enter your GitHub username:');
|
||||
const tokenInput = prompt('Please enter your GitHub personal access token:');
|
||||
|
||||
if (!usernameInput || !tokenInput) {
|
||||
alert('GitHub username and token are required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, usernameInput, tokenInput);
|
||||
} else {
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
}
|
||||
|
||||
const githubToken = prompt('Please enter your GitHub personal access token:');
|
||||
|
||||
if (!githubToken) {
|
||||
alert('GitHub token is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:github-logo" />
|
||||
|
||||
Reference in New Issue
Block a user