Hardcode url for together ai as fallback if not set in env
This commit is contained in:
@@ -10,11 +10,8 @@ interface APIKeyManagerProps {
|
||||
labelForGetApiKey?: string;
|
||||
}
|
||||
|
||||
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({
|
||||
provider,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempKey, setTempKey] = useState(apiKey);
|
||||
|
||||
@@ -24,15 +21,29 @@ export const APIKeyManager: React.FC<APIKeyManagerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2 mb-2">
|
||||
<span className="text-sm text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
|
||||
<div className="flex items-start sm:items-center mt-2 mb-2 flex-col sm:flex-row">
|
||||
<div>
|
||||
<span className="text-sm text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
|
||||
{!isEditing && (
|
||||
<div className="flex items-center mb-4">
|
||||
<span className="flex-1 text-xs text-bolt-elements-textPrimary mr-2">
|
||||
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'}
|
||||
</span>
|
||||
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key">
|
||||
<div className="i-ph:pencil-simple" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<input
|
||||
type="password"
|
||||
value={tempKey}
|
||||
placeholder="Your API Key"
|
||||
onChange={(e) => setTempKey(e.target.value)}
|
||||
className="flex-1 p-1 text-sm rounded 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"
|
||||
className="flex-1 px-2 py-1 text-xs lg:text-sm rounded 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"
|
||||
/>
|
||||
<IconButton onClick={handleSave} title="Save API Key">
|
||||
<div className="i-ph:check" />
|
||||
@@ -40,20 +51,15 @@ export const APIKeyManager: React.FC<APIKeyManagerProps> = ({
|
||||
<IconButton onClick={() => setIsEditing(false)} title="Cancel">
|
||||
<div className="i-ph:x" />
|
||||
</IconButton>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-bolt-elements-textPrimary">
|
||||
{apiKey ? '••••••••' : 'Not set (will still work if set in .env file)'}
|
||||
</span>
|
||||
<IconButton onClick={() => setIsEditing(true)} title="Edit API Key">
|
||||
<div className="i-ph:pencil-simple" />
|
||||
</IconButton>
|
||||
|
||||
{provider?.getApiKeyLink && <IconButton onClick={() => window.open(provider?.getApiKeyLink)} title="Edit API Key">
|
||||
<span className="mr-2">{provider?.labelForGetApiKey || 'Get API Key'}</span>
|
||||
<div className={provider?.icon || "i-ph:key"} />
|
||||
</IconButton>}
|
||||
{provider?.getApiKeyLink && (
|
||||
<IconButton className="ml-auto" onClick={() => window.open(provider?.getApiKeyLink)} title="Edit API Key">
|
||||
<span className="mr-2 text-xs lg:text-sm">{provider?.labelForGetApiKey || 'Get API Key'}</span>
|
||||
<div className={provider?.icon || 'i-ph:key'} />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,3 +17,107 @@
|
||||
.Chat {
|
||||
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;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
inset: calc(var(--prompt-container-offset) / -2);
|
||||
width: calc(100% + var(--prompt-container-offset));
|
||||
height: calc(100% + var(--prompt-container-offset));
|
||||
}
|
||||
|
||||
.PromptEffectLine {
|
||||
width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
||||
height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
||||
x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
||||
y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
||||
rx: calc(8px - var(--prompt-line-stroke-width));
|
||||
fill: transparent;
|
||||
stroke-width: var(--prompt-line-stroke-width);
|
||||
stroke: url(#line-gradient);
|
||||
stroke-dasharray: 35px 65px;
|
||||
stroke-dashoffset: 10;
|
||||
}
|
||||
|
||||
.PromptShine {
|
||||
fill: url(#shine-gradient);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import type { Message } from 'ai';
|
||||
import React, { type RefCallback, useEffect } from 'react';
|
||||
import React, { type RefCallback, useEffect, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { Workbench } from '~/components/workbench/Workbench.client';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { MODEL_LIST, DEFAULT_PROVIDER, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
|
||||
import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
|
||||
import { Messages } from './Messages.client';
|
||||
import { SendButton } from './SendButton.client';
|
||||
import { useState } from 'react';
|
||||
import { APIKeyManager } from './APIKeyManager';
|
||||
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';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Build a todo app in React using Tailwind' },
|
||||
{ text: 'Build a simple blog using Astro' },
|
||||
{ text: 'Create a cookie consent form using Material UI' },
|
||||
{ text: 'Make a space invaders game' },
|
||||
{ text: 'How do I center a div?' },
|
||||
];
|
||||
import FilePreview from './FilePreview';
|
||||
|
||||
const providerList = PROVIDER_LIST;
|
||||
|
||||
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList }) => {
|
||||
// @ts-ignore TODO: Introduce proper types
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
|
||||
return (
|
||||
<div className="mb-2 flex gap-2">
|
||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
value={provider?.name}
|
||||
onChange={(e) => {
|
||||
setProvider(providerList.find((p) => p.name === e.target.value));
|
||||
setProvider(providerList.find((p: ProviderInfo) => p.name === e.target.value));
|
||||
|
||||
const firstModel = [...modelList].find((m) => m.provider == e.target.value);
|
||||
setModel(firstModel ? firstModel.name : '');
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{providerList.map((provider) => (
|
||||
{providerList.map((provider: ProviderInfo) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.name}
|
||||
</option>
|
||||
@@ -49,8 +49,7 @@ const ModelSelector = ({ model, setModel, provider, setProvider, modelList, prov
|
||||
key={provider?.name}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
style={{ maxWidth: '70%' }}
|
||||
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"
|
||||
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-[70%]"
|
||||
>
|
||||
{[...modelList]
|
||||
.filter((e) => e.provider == provider?.name && e.name)
|
||||
@@ -74,6 +73,7 @@ interface BaseChatProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
messages?: Message[];
|
||||
description?: string;
|
||||
enhancingPrompt?: boolean;
|
||||
promptEnhanced?: boolean;
|
||||
input?: string;
|
||||
@@ -85,13 +85,19 @@ interface BaseChatProps {
|
||||
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
enhancePrompt?: () => void;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
exportChat?: () => void;
|
||||
uploadedFiles?: File[];
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
imageDataList?: string[];
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
}
|
||||
|
||||
const SpeechRecognitionButton = ({
|
||||
isListening,
|
||||
onStart,
|
||||
onStop,
|
||||
disabled
|
||||
disabled,
|
||||
}: {
|
||||
isListening: boolean;
|
||||
onStart: () => void;
|
||||
@@ -100,18 +106,14 @@ const SpeechRecognitionButton = ({
|
||||
}) => {
|
||||
return (
|
||||
<IconButton
|
||||
title={isListening ? "Stop listening" : "Start speech recognition"}
|
||||
title={isListening ? 'Stop listening' : 'Start speech recognition'}
|
||||
disabled={disabled}
|
||||
className={classNames('transition-all', {
|
||||
'text-bolt-elements-item-contentAccent': isListening,
|
||||
})}
|
||||
onClick={isListening ? onStop : onStart}
|
||||
>
|
||||
{isListening ? (
|
||||
<div className="i-ph:microphone-slash text-xl" />
|
||||
) : (
|
||||
<div className="i-ph:microphone text-xl" />
|
||||
)}
|
||||
{isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
@@ -125,40 +127,51 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
isStreaming = false,
|
||||
enhancingPrompt = false,
|
||||
promptEnhanced = false,
|
||||
messages,
|
||||
input = '',
|
||||
model,
|
||||
setModel,
|
||||
provider,
|
||||
setProvider,
|
||||
sendMessage,
|
||||
input = '',
|
||||
enhancingPrompt,
|
||||
handleInputChange,
|
||||
promptEnhanced,
|
||||
enhancePrompt,
|
||||
sendMessage,
|
||||
handleStop,
|
||||
importChat,
|
||||
exportChat,
|
||||
uploadedFiles = [],
|
||||
setUploadedFiles,
|
||||
imageDataList = [],
|
||||
setImageDataList,
|
||||
messages,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [modelList, setModelList] = useState(MODEL_LIST);
|
||||
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
|
||||
console.log(transcript);
|
||||
useEffect(() => {
|
||||
// Load API keys from cookies on component mount
|
||||
try {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (storedApiKeys) {
|
||||
const parsedKeys = JSON.parse(storedApiKeys);
|
||||
|
||||
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
|
||||
setApiKeys(parsedKeys);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading API keys from cookies:', error);
|
||||
|
||||
// Clear invalid cookie data
|
||||
Cookies.remove('apiKeys');
|
||||
}
|
||||
@@ -166,6 +179,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
initializeModelList().then((modelList) => {
|
||||
setModelList(modelList);
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
@@ -174,13 +188,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const transcript = Array.from(event.results)
|
||||
.map(result => result[0])
|
||||
.map(result => result.transcript)
|
||||
.map((result) => result[0])
|
||||
.map((result) => result.transcript)
|
||||
.join('');
|
||||
|
||||
setTranscript(transcript);
|
||||
|
||||
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: transcript },
|
||||
@@ -215,6 +228,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
||||
if (sendMessage) {
|
||||
sendMessage(event, messageInput);
|
||||
|
||||
if (recognition) {
|
||||
recognition.abort(); // Stop current recognition
|
||||
setTranscript(''); // Clear transcript
|
||||
@@ -235,6 +249,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
try {
|
||||
const updatedApiKeys = { ...apiKeys, [provider]: key };
|
||||
setApiKeys(updatedApiKeys);
|
||||
|
||||
// Save updated API keys to cookies with 30 day expiry and secure settings
|
||||
Cookies.set('apiKeys', JSON.stringify(updatedApiKeys), {
|
||||
expires: 30, // 30 days
|
||||
@@ -247,30 +262,89 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const handleFileUpload = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const baseChat = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.BaseChat,
|
||||
'relative flex h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
|
||||
'relative flex flex-col lg:flex-row h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
|
||||
)}
|
||||
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 overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
|
||||
<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">
|
||||
<h1 className="text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
<div id="intro" className="mt-[26vh] 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>
|
||||
<p className="text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
Bring ideas to life in seconds or get help on existing projects.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('pt-6 px-6', {
|
||||
className={classNames('pt-6 px-2 sm:px-6', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
>
|
||||
@@ -279,7 +353,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
@@ -288,35 +362,124 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</ClientOnly>
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6',
|
||||
{
|
||||
'sticky bottom-0': chatStarted
|
||||
})}
|
||||
>
|
||||
<ModelSelector
|
||||
key={provider?.name + ':' + modelList.length}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
modelList={modelList}
|
||||
provider={provider}
|
||||
setProvider={setProvider}
|
||||
providerList={PROVIDER_LIST}
|
||||
/>
|
||||
{provider && (
|
||||
<APIKeyManager
|
||||
provider={provider}
|
||||
apiKey={apiKeys[provider.name] || ''}
|
||||
setApiKey={(key) => updateApiKey(provider.name, key)}
|
||||
/>
|
||||
'sticky bottom-2': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<svg className={classNames(styles.PromptEffectContainer)}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="-14%"
|
||||
y2="10%"
|
||||
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>
|
||||
</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="100%" stopColor="white" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect>
|
||||
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<button
|
||||
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
|
||||
className={classNames('flex items-center gap-2 p-2 rounded-lg transition-all', {
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
|
||||
isModelSettingsCollapsed,
|
||||
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
|
||||
!isModelSettingsCollapsed,
|
||||
})}
|
||||
>
|
||||
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
|
||||
<span>Model Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
|
||||
<ModelSelector
|
||||
key={provider?.name + ':' + modelList.length}
|
||||
model={model}
|
||||
setModel={setModel}
|
||||
modelList={modelList}
|
||||
provider={provider}
|
||||
setProvider={setProvider}
|
||||
providerList={PROVIDER_LIST}
|
||||
apiKeys={apiKeys}
|
||||
/>
|
||||
{provider && (
|
||||
<APIKeyManager
|
||||
provider={provider}
|
||||
apiKey={apiKeys[provider.name] || ''}
|
||||
setApiKey={(key) => updateApiKey(provider.name, key)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilePreview
|
||||
files={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
onRemove={(index) => {
|
||||
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
|
||||
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'shadow-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden transition-all',
|
||||
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`w-full pl-4 pt-4 pr-16 focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent transition-all`}
|
||||
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',
|
||||
'transition-all duration-200',
|
||||
'hover:border-bolt-elements-focus',
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
@@ -324,10 +487,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
@@ -335,6 +500,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
onChange={(event) => {
|
||||
handleInputChange?.(event);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
@@ -345,7 +511,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<SendButton
|
||||
show={input.length > 0 || isStreaming}
|
||||
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
||||
isStreaming={isStreaming}
|
||||
onClick={(event) => {
|
||||
if (isStreaming) {
|
||||
@@ -353,21 +519,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event);
|
||||
if (input.length > 0 || uploadedFiles.length > 0) {
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
||||
<div className="i-ph:paperclip text-xl"></div>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
title="Enhance prompt"
|
||||
disabled={input.length === 0 || enhancingPrompt}
|
||||
className={classNames('transition-all', {
|
||||
'opacity-100!': enhancingPrompt,
|
||||
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
|
||||
promptEnhanced,
|
||||
})}
|
||||
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?.()}
|
||||
>
|
||||
{enhancingPrompt ? (
|
||||
@@ -389,47 +562,35 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
onStop={stopListening}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> for
|
||||
a new line
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a
|
||||
new line
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bolt-elements-background-depth-1 pb-6">{/* Ghost Element */}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!chatStarted && (
|
||||
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
|
||||
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
|
||||
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
handleSendMessage?.(event, examplePrompt.text);
|
||||
}}
|
||||
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
|
||||
>
|
||||
{examplePrompt.text}
|
||||
<div className="i-ph:arrow-bend-down-left" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!chatStarted && ImportButtons(importChat)}
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { Message } from 'ai';
|
||||
import { useChat } from 'ai/react';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
|
||||
import { useChatHistory } from '~/lib/persistence';
|
||||
import { description, useChatHistory } from '~/lib/persistence';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { fileModificationsToHTML } from '~/utils/diff';
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROVIDER_LIST } from '~/utils/constants';
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
|
||||
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';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -28,11 +30,20 @@ const logger = createScopedLogger('Chat');
|
||||
export function Chat() {
|
||||
renderLogger.trace('Chat');
|
||||
|
||||
const { ready, initialMessages, storeMessageHistory } = useChatHistory();
|
||||
const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
|
||||
const title = useStore(description);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
|
||||
{ready && (
|
||||
<ChatImpl
|
||||
description={title}
|
||||
initialMessages={initialMessages}
|
||||
exportChat={exportChat}
|
||||
storeMessageHistory={storeMessageHistory}
|
||||
importChat={importChat}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer
|
||||
closeButton={({ closeToast }) => {
|
||||
return (
|
||||
@@ -67,213 +78,281 @@ export function Chat() {
|
||||
interface ChatProps {
|
||||
initialMessages: Message[];
|
||||
storeMessageHistory: (messages: Message[]) => Promise<void>;
|
||||
importChat: (description: string, messages: Message[]) => Promise<void>;
|
||||
exportChat: () => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
|
||||
useShortcuts();
|
||||
export const ChatImpl = memo(
|
||||
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
|
||||
useShortcuts();
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||
|
||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
return savedModel || DEFAULT_MODEL;
|
||||
});
|
||||
const [provider, setProvider] = useState(() => {
|
||||
const savedProvider = Cookies.get('selectedProvider');
|
||||
return PROVIDER_LIST.find(p => p.name === savedProvider) || DEFAULT_PROVIDER;
|
||||
});
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
return savedModel || DEFAULT_MODEL;
|
||||
});
|
||||
const [provider, setProvider] = useState(() => {
|
||||
const savedProvider = Cookies.get('selectedProvider');
|
||||
return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
|
||||
});
|
||||
|
||||
const { showChat } = useStore(chatStore);
|
||||
const { showChat } = useStore(chatStore);
|
||||
|
||||
const [animationScope, animate] = useAnimate();
|
||||
const [animationScope, animate] = useAnimate();
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
|
||||
api: '/api/chat',
|
||||
body: {
|
||||
apiKeys
|
||||
},
|
||||
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: () => {
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
initialMessages,
|
||||
});
|
||||
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
|
||||
api: '/api/chat',
|
||||
body: {
|
||||
apiKeys,
|
||||
},
|
||||
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: () => {
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
initialMessages,
|
||||
initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '',
|
||||
});
|
||||
|
||||
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
||||
const { parsedMessages, parseMessages } = useMessageParser();
|
||||
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
||||
const { parsedMessages, parseMessages } = useMessageParser();
|
||||
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.setKey('started', initialMessages.length > 0);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
chatStore.setKey('started', initialMessages.length > 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
parseMessages(messages, isLoading);
|
||||
useEffect(() => {
|
||||
parseMessages(messages, isLoading);
|
||||
|
||||
if (messages.length > initialMessages.length) {
|
||||
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
||||
}
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
if (messages.length > initialMessages.length) {
|
||||
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
||||
}
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
|
||||
const scrollTextArea = () => {
|
||||
const textarea = textareaRef.current;
|
||||
const scrollTextArea = () => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
};
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
stop();
|
||||
chatStore.setKey('aborted', true);
|
||||
workbenchStore.abortAllActions();
|
||||
};
|
||||
const abort = () => {
|
||||
stop();
|
||||
chatStore.setKey('aborted', true);
|
||||
workbenchStore.abortAllActions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
|
||||
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
||||
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
|
||||
}
|
||||
}, [input, textareaRef]);
|
||||
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
||||
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
|
||||
}
|
||||
}, [input, textareaRef]);
|
||||
|
||||
const runAnimation = async () => {
|
||||
if (chatStarted) {
|
||||
return;
|
||||
}
|
||||
const runAnimation = async () => {
|
||||
if (chatStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
||||
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
||||
]);
|
||||
await Promise.all([
|
||||
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
||||
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
||||
]);
|
||||
|
||||
chatStore.setKey('started', true);
|
||||
chatStore.setKey('started', true);
|
||||
|
||||
setChatStarted(true);
|
||||
};
|
||||
setChatStarted(true);
|
||||
};
|
||||
|
||||
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
||||
const _input = messageInput || input;
|
||||
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
||||
const _input = messageInput || input;
|
||||
|
||||
if (_input.length === 0 || isLoading) {
|
||||
return;
|
||||
}
|
||||
if (_input.length === 0 || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
|
||||
* many unsaved files. In that case we need to block user input and show an indicator
|
||||
* of some kind so the user is aware that something is happening. But I consider the
|
||||
* happy case to be no unsaved files and I would expect users to save their changes
|
||||
* before they send another message.
|
||||
*/
|
||||
await workbenchStore.saveAllFiles();
|
||||
|
||||
const fileModifications = workbenchStore.getFileModifcations();
|
||||
|
||||
chatStore.setKey('aborted', false);
|
||||
|
||||
runAnimation();
|
||||
|
||||
if (fileModifications !== undefined) {
|
||||
/**
|
||||
* If we have file modifications we append a new user message manually since we have to prefix
|
||||
* the user input with the file modifications and we don't want the new user input to appear
|
||||
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
|
||||
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
||||
* aren't relevant here.
|
||||
*/
|
||||
append({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
image: imageData,
|
||||
})),
|
||||
] as any, // Type assertion to bypass compiler check
|
||||
});
|
||||
|
||||
/**
|
||||
* After sending a new message we reset all modifications since the model
|
||||
* should now be aware of all the changes.
|
||||
*/
|
||||
workbenchStore.resetAllFileModifications();
|
||||
} else {
|
||||
append({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
||||
},
|
||||
...imageDataList.map((imageData) => ({
|
||||
type: 'image',
|
||||
image: imageData,
|
||||
})),
|
||||
] as any, // Type assertion to bypass compiler check
|
||||
});
|
||||
}
|
||||
|
||||
setInput('');
|
||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
||||
|
||||
// Add file cleanup here
|
||||
setUploadedFiles([]);
|
||||
setImageDataList([]);
|
||||
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
};
|
||||
|
||||
/**
|
||||
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
|
||||
* many unsaved files. In that case we need to block user input and show an indicator
|
||||
* of some kind so the user is aware that something is happening. But I consider the
|
||||
* happy case to be no unsaved files and I would expect users to save their changes
|
||||
* before they send another message.
|
||||
* Handles the change event for the textarea and updates the input state.
|
||||
* @param event - The change event from the textarea.
|
||||
*/
|
||||
await workbenchStore.saveAllFiles();
|
||||
const onTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
handleInputChange(event);
|
||||
};
|
||||
|
||||
const fileModifications = workbenchStore.getFileModifcations();
|
||||
/**
|
||||
* Debounced function to cache the prompt in cookies.
|
||||
* Caches the trimmed value of the textarea input after a delay to optimize performance.
|
||||
*/
|
||||
const debouncedCachePrompt = useCallback(
|
||||
debounce((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const trimmedValue = event.target.value.trim();
|
||||
Cookies.set(PROMPT_COOKIE_KEY, trimmedValue, { expires: 30 });
|
||||
}, 1000),
|
||||
[],
|
||||
);
|
||||
|
||||
chatStore.setKey('aborted', false);
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
runAnimation();
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
if (fileModifications !== undefined) {
|
||||
const diff = fileModificationsToHTML(fileModifications);
|
||||
if (storedApiKeys) {
|
||||
setApiKeys(JSON.parse(storedApiKeys));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* If we have file modifications we append a new user message manually since we have to prefix
|
||||
* the user input with the file modifications and we don't want the new user input to appear
|
||||
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
|
||||
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
||||
* aren't relevant here.
|
||||
*/
|
||||
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
|
||||
const handleModelChange = (newModel: string) => {
|
||||
setModel(newModel);
|
||||
Cookies.set('selectedModel', newModel, { expires: 30 });
|
||||
};
|
||||
|
||||
/**
|
||||
* After sending a new message we reset all modifications since the model
|
||||
* should now be aware of all the changes.
|
||||
*/
|
||||
workbenchStore.resetAllFileModifications();
|
||||
} else {
|
||||
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
|
||||
}
|
||||
const handleProviderChange = (newProvider: ProviderInfo) => {
|
||||
setProvider(newProvider);
|
||||
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
|
||||
};
|
||||
|
||||
setInput('');
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
showChat={showChat}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
model={model}
|
||||
setModel={handleModelChange}
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
onTextareaChange(e);
|
||||
debouncedCachePrompt(e);
|
||||
}}
|
||||
handleStop={abort}
|
||||
description={description}
|
||||
importChat={importChat}
|
||||
exportChat={exportChat}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
};
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
if (storedApiKeys) {
|
||||
setApiKeys(JSON.parse(storedApiKeys));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModelChange = (newModel: string) => {
|
||||
setModel(newModel);
|
||||
Cookies.set('selectedModel', newModel, { expires: 30 });
|
||||
};
|
||||
|
||||
const handleProviderChange = (newProvider: ProviderInfo) => {
|
||||
setProvider(newProvider);
|
||||
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
showChat={showChat}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isLoading}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
model={model}
|
||||
setModel={handleModelChange}
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={handleInputChange}
|
||||
handleStop={abort}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(
|
||||
input,
|
||||
(input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
},
|
||||
model,
|
||||
provider,
|
||||
apiKeys
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(
|
||||
input,
|
||||
(input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
},
|
||||
model,
|
||||
provider,
|
||||
apiKeys,
|
||||
);
|
||||
}}
|
||||
uploadedFiles={uploadedFiles}
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
setImageDataList={setImageDataList}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
36
app/components/chat/ExamplePrompts.tsx
Normal file
36
app/components/chat/ExamplePrompts.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Build a todo app in React using Tailwind' },
|
||||
{ text: 'Build a simple blog using Astro' },
|
||||
{ text: 'Create a cookie consent form using Material UI' },
|
||||
{ text: 'Make a space invaders game' },
|
||||
{ text: 'Make a Tic Tac Toe game in html, css and js only' },
|
||||
];
|
||||
|
||||
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
|
||||
return (
|
||||
<div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-6">
|
||||
<div
|
||||
className="flex flex-wrap justify-center gap-2"
|
||||
style={{
|
||||
animation: '.25s ease-out 0s 1 _fade-and-move-in_g2ptj_1 forwards',
|
||||
}}
|
||||
>
|
||||
{EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event) => {
|
||||
sendMessage?.(event, examplePrompt.text);
|
||||
}}
|
||||
className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-theme"
|
||||
>
|
||||
{examplePrompt.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
app/components/chat/FilePreview.tsx
Normal file
35
app/components/chat/FilePreview.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FilePreviewProps {
|
||||
files: File[];
|
||||
imageDataList: string[];
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row overflow-x-auto -mt-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name + file.size} className="mr-2 relative">
|
||||
{imageDataList[index] && (
|
||||
<div className="relative pt-4 pr-4">
|
||||
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
164
app/components/chat/ImportFolderButton.tsx
Normal file
164
app/components/chat/ImportFolderButton.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import ignore from 'ignore';
|
||||
|
||||
interface ImportFolderButtonProps {
|
||||
className?: string;
|
||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// Common patterns to ignore, similar to .gitignore
|
||||
const IGNORE_PATTERNS = [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'.cache/**',
|
||||
'.vscode/**',
|
||||
'.idea/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/npm-debug.log*',
|
||||
'**/yarn-debug.log*',
|
||||
'**/yarn-error.log*',
|
||||
];
|
||||
|
||||
const ig = ignore().add(IGNORE_PATTERNS);
|
||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
|
||||
const isBinaryFile = async (file: File): Promise<boolean> => {
|
||||
const chunkSize = 1024; // Read the first 1 KB of the file
|
||||
const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer());
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) {
|
||||
return true; // Found a binary character
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
||||
const shouldIncludeFile = (path: string): boolean => {
|
||||
return !ig.ignores(path);
|
||||
};
|
||||
|
||||
const createChatFromFolder = async (files: File[], binaryFiles: string[]) => {
|
||||
const fileArtifacts = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string;
|
||||
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/');
|
||||
resolve(
|
||||
`<boltAction type="file" filePath="${relativePath}">
|
||||
${content}
|
||||
</boltAction>`,
|
||||
);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const binaryFilesMessage =
|
||||
binaryFiles.length > 0
|
||||
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const message: Message = {
|
||||
role: 'assistant',
|
||||
content: `I'll help you set up these files.${binaryFilesMessage}
|
||||
|
||||
<boltArtifact id="imported-files" title="Imported Files">
|
||||
${fileArtifacts.join('\n\n')}
|
||||
</boltArtifact>`,
|
||||
id: generateId(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
id: generateId(),
|
||||
content: 'Import my files',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`;
|
||||
|
||||
if (importChat) {
|
||||
await importChat(description, [userMessage, message]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
id="folder-import"
|
||||
className="hidden"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={async (e) => {
|
||||
const allFiles = Array.from(e.target.files || []);
|
||||
const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath));
|
||||
|
||||
if (filteredFiles.length === 0) {
|
||||
toast.error('No files found in the selected folder');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileChecks = await Promise.all(
|
||||
filteredFiles.map(async (file) => ({
|
||||
file,
|
||||
isBinary: await isBinaryFile(file),
|
||||
})),
|
||||
);
|
||||
|
||||
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
||||
const binaryFilePaths = fileChecks
|
||||
.filter((f) => f.isBinary)
|
||||
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
toast.error('No text files found in the selected folder');
|
||||
return;
|
||||
}
|
||||
|
||||
if (binaryFilePaths.length > 0) {
|
||||
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
||||
}
|
||||
|
||||
await createChatFromFolder(textFiles, binaryFilePaths);
|
||||
} catch (error) {
|
||||
console.error('Failed to import folder:', error);
|
||||
toast.error('Failed to import folder');
|
||||
}
|
||||
|
||||
e.target.value = ''; // Reset file input
|
||||
}}
|
||||
{...({} as any)} // if removed webkitdirectory will throw errors as unknow attribute
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const input = document.getElementById('folder-import');
|
||||
input?.click();
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<div className="i-ph:upload-simple" />
|
||||
Import Folder
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
app/components/chat/Markdown.spec.ts
Normal file
48
app/components/chat/Markdown.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stripCodeFenceFromArtifact } from './Markdown';
|
||||
|
||||
describe('stripCodeFenceFromArtifact', () => {
|
||||
it('should remove code fences around artifact element', () => {
|
||||
const input = "```xml\n<div class='__boltArtifact__'></div>\n```";
|
||||
const expected = "\n<div class='__boltArtifact__'></div>\n";
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle code fence with language specification', () => {
|
||||
const input = "```typescript\n<div class='__boltArtifact__'></div>\n```";
|
||||
const expected = "\n<div class='__boltArtifact__'></div>\n";
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not modify content without artifacts', () => {
|
||||
const input = '```\nregular code block\n```';
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(stripCodeFenceFromArtifact('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle artifact without code fences', () => {
|
||||
const input = "<div class='__boltArtifact__'></div>";
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts but only remove fences around them', () => {
|
||||
const input = [
|
||||
'Some text',
|
||||
'```typescript',
|
||||
"<div class='__boltArtifact__'></div>",
|
||||
'```',
|
||||
'```',
|
||||
'regular code',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const expected = ['Some text', '', "<div class='__boltArtifact__'></div>", '', '```', 'regular code', '```'].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
expect(stripCodeFenceFromArtifact(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,51 @@ export const Markdown = memo(({ children, html = false, limitedMarkdown = false
|
||||
remarkPlugins={remarkPlugins(limitedMarkdown)}
|
||||
rehypePlugins={rehypePlugins(html)}
|
||||
>
|
||||
{children}
|
||||
{stripCodeFenceFromArtifact(children)}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Removes code fence markers (```) surrounding an artifact element while preserving the artifact content.
|
||||
* This is necessary because artifacts should not be wrapped in code blocks when rendered for rendering action list.
|
||||
*
|
||||
* @param content - The markdown content to process
|
||||
* @returns The processed content with code fence markers removed around artifacts
|
||||
*
|
||||
* @example
|
||||
* // Removes code fences around artifact
|
||||
* const input = "```xml\n<div class='__boltArtifact__'></div>\n```";
|
||||
* stripCodeFenceFromArtifact(input);
|
||||
* // Returns: "\n<div class='__boltArtifact__'></div>\n"
|
||||
*
|
||||
* @remarks
|
||||
* - Only removes code fences that directly wrap an artifact (marked with __boltArtifact__ class)
|
||||
* - Handles code fences with optional language specifications (e.g. ```xml, ```typescript)
|
||||
* - Preserves original content if no artifact is found
|
||||
* - Safely handles edge cases like empty input or artifacts at start/end of content
|
||||
*/
|
||||
export const stripCodeFenceFromArtifact = (content: string) => {
|
||||
if (!content || !content.includes('__boltArtifact__')) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const artifactLineIndex = lines.findIndex((line) => line.includes('__boltArtifact__'));
|
||||
|
||||
// Return original content if artifact line not found
|
||||
if (artifactLineIndex === -1) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Check previous line for code fence
|
||||
if (artifactLineIndex > 0 && lines[artifactLineIndex - 1]?.trim().match(/^```\w*$/)) {
|
||||
lines[artifactLineIndex - 1] = '';
|
||||
}
|
||||
|
||||
if (artifactLineIndex < lines.length - 1 && lines[artifactLineIndex + 1]?.trim().match(/^```$/)) {
|
||||
lines[artifactLineIndex + 1] = '';
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useLocation, useNavigate } from '@remix-run/react';
|
||||
import { useLocation } from '@remix-run/react';
|
||||
import { db, chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { forkChat } from '~/lib/persistence/db';
|
||||
import { toast } from 'react-toastify';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
@@ -19,7 +19,6 @@ interface MessagesProps {
|
||||
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
||||
const { id, isStreaming = false, messages = [] } = props;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRewind = (messageId: string) => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -42,88 +41,66 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<div id={id} ref={ref} className={props.className}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, index) => {
|
||||
const { role, content, id: messageId } = message;
|
||||
const isUserMessage = role === 'user';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === messages.length - 1;
|
||||
<div id={id} ref={ref} className={props.className}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, index) => {
|
||||
const { role, content, id: messageId } = message;
|
||||
const isUserMessage = role === 'user';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
||||
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
||||
isStreaming && isLast,
|
||||
'mt-4': !isFirst,
|
||||
})}
|
||||
>
|
||||
{isUserMessage && (
|
||||
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
||||
<div className="i-ph:user-fill text-xl"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-col-1 w-full">
|
||||
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
||||
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
||||
isStreaming && isLast,
|
||||
'mt-4': !isFirst,
|
||||
})}
|
||||
>
|
||||
{isUserMessage && (
|
||||
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
||||
<div className="i-ph:user-fill text-xl"></div>
|
||||
</div>
|
||||
{!isUserMessage && (<div className="flex gap-2">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
{messageId && (<button
|
||||
)}
|
||||
<div className="grid grid-col-1 w-full">
|
||||
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
{messageId && (
|
||||
<button
|
||||
onClick={() => handleRewind(messageId)}
|
||||
key='i-ph:arrow-u-up-left'
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className={classNames(
|
||||
'i-ph:arrow-u-up-left',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors'
|
||||
)}
|
||||
/>)}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
|
||||
sideOffset={5}
|
||||
style={{zIndex: 1000}}
|
||||
>
|
||||
Revert to this message
|
||||
<Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={() => handleFork(messageId)}
|
||||
key='i-ph:git-fork'
|
||||
className={classNames(
|
||||
'i-ph:git-fork',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors'
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
|
||||
sideOffset={5}
|
||||
style={{zIndex: 1000}}
|
||||
>
|
||||
Fork chat from this message
|
||||
<Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{isStreaming && (
|
||||
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)}
|
||||
</WithTooltip>
|
||||
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => handleFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className={classNames(
|
||||
'i-ph:git-fork',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{isStreaming && (
|
||||
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,11 +4,12 @@ interface SendButtonProps {
|
||||
show: boolean;
|
||||
isStreaming?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onImagesSelected?: (images: File[]) => void;
|
||||
}
|
||||
|
||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
|
||||
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
||||
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
@@ -30,4 +31,4 @@ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
// @ts-nocheck
|
||||
// Preventing TS checks with files presented in the video for a better presentation.
|
||||
import { modificationsRegex } from '~/utils/diff';
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string;
|
||||
content: string | Array<{ type: string; text?: string; image?: string }>;
|
||||
}
|
||||
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
if (Array.isArray(content)) {
|
||||
const textItem = content.find((item) => item.type === 'text');
|
||||
const textContent = sanitizeUserMessage(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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = sanitizeUserMessage(content);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
|
||||
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeUserMessage(content: string) {
|
||||
return content.replace(modificationsRegex, '').replace(MODEL_REGEX, 'Using: $1').replace(PROVIDER_REGEX, ' ($1)\n\n').trim();
|
||||
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||
}
|
||||
|
||||
13
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
13
app/components/chat/chatExportAndImport/ExportChatButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import React from 'react';
|
||||
|
||||
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
|
||||
return (
|
||||
<WithTooltip tooltip="Export Chat">
|
||||
<IconButton title="Export Chat" onClick={() => exportChat?.()}>
|
||||
<div className="i-ph:download-simple text-xl"></div>
|
||||
</IconButton>
|
||||
</WithTooltip>
|
||||
);
|
||||
};
|
||||
71
app/components/chat/chatExportAndImport/ImportButtons.tsx
Normal file
71
app/components/chat/chatExportAndImport/ImportButtons.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import React from 'react';
|
||||
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
|
||||
|
||||
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<input
|
||||
type="file"
|
||||
id="chat-import"
|
||||
className="hidden"
|
||||
accept=".json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file && importChat) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(data.messages)) {
|
||||
toast.error('Invalid chat file format');
|
||||
}
|
||||
|
||||
await importChat(data.description, data.messages);
|
||||
toast.success('Chat imported successfully');
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error('Failed to parse chat file: ' + error.message);
|
||||
} else {
|
||||
toast.error('Failed to parse chat file');
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.onerror = () => toast.error('Failed to read chat file');
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to import chat');
|
||||
}
|
||||
e.target.value = ''; // Reset file input
|
||||
} else {
|
||||
toast.error('Something went wrong');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-4 max-w-2xl text-center">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const input = document.getElementById('chat-import');
|
||||
input?.click();
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<div className="i-ph:upload-simple" />
|
||||
Import Chat
|
||||
</button>
|
||||
<ImportFolderButton
|
||||
importChat={importChat}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,17 +24,19 @@ export function Header() {
|
||||
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
||||
</a>
|
||||
</div>
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||
</span>
|
||||
{chat.started && (
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||
<>
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||
</span>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
@@ -9,6 +10,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const { showChat } = useStore(chatStore);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
|
||||
return (
|
||||
@@ -16,7 +19,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||
<Button
|
||||
active={showChat}
|
||||
disabled={!canHideChat}
|
||||
disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed
|
||||
onClick={() => {
|
||||
if (canHideChat) {
|
||||
chatStore.setKey('showChat', !showChat);
|
||||
|
||||
@@ -1,72 +1,127 @@
|
||||
import { useParams } from '@remix-run/react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useEditChatDescription } from '~/lib/hooks';
|
||||
|
||||
interface HistoryItemProps {
|
||||
item: ChatHistoryItem;
|
||||
onDelete?: (event: React.UIEvent) => void;
|
||||
onDuplicate?: (id: string) => void;
|
||||
exportChat: (id?: string) => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const hoverRef = useRef<HTMLDivElement>(null);
|
||||
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
||||
const { id: urlId } = useParams();
|
||||
const isActiveChat = urlId === item.urlId;
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||
useEditChatDescription({
|
||||
initialDescription: item.description,
|
||||
customChatId: item.id,
|
||||
syncWithGlobalStore: isActiveChat,
|
||||
});
|
||||
|
||||
function mouseEnter() {
|
||||
setHovering(true);
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function mouseLeave() {
|
||||
setHovering(false);
|
||||
}
|
||||
|
||||
hoverRef.current?.addEventListener('mouseenter', mouseEnter);
|
||||
hoverRef.current?.addEventListener('mouseleave', mouseLeave);
|
||||
|
||||
return () => {
|
||||
hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
|
||||
hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
|
||||
};
|
||||
}, []);
|
||||
const renderDescriptionForm = (
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
|
||||
autoFocus
|
||||
value={currentDescription}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||
onMouseDown={handleSubmit}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hoverRef}
|
||||
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
|
||||
className={classNames(
|
||||
'group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1',
|
||||
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
|
||||
)}
|
||||
>
|
||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||
{item.description}
|
||||
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
|
||||
{hovering && (
|
||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary">
|
||||
{editing ? (
|
||||
renderDescriptionForm
|
||||
) : (
|
||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||
{currentDescription}
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-22 group-hover:from-99%',
|
||||
{ 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChatActionButton
|
||||
toolTipContent="Export chat"
|
||||
icon="i-ph:download-simple"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
exportChat(item.id);
|
||||
}}
|
||||
/>
|
||||
{onDuplicate && (
|
||||
<button
|
||||
className="i-ph:copy scale-110 mr-2"
|
||||
<ChatActionButton
|
||||
toolTipContent="Duplicate chat"
|
||||
icon="i-ph:copy"
|
||||
onClick={() => onDuplicate?.(item.id)}
|
||||
title="Duplicate chat"
|
||||
/>
|
||||
)}
|
||||
<ChatActionButton
|
||||
toolTipContent="Rename chat"
|
||||
icon="i-ph:pencil-fill"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleEditMode();
|
||||
}}
|
||||
/>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
className="i-ph:trash scale-110"
|
||||
<ChatActionButton
|
||||
toolTipContent="Delete chat"
|
||||
icon="i-ph:trash"
|
||||
className="[&&]:hover:text-bolt-elements-button-danger-text"
|
||||
onClick={(event) => {
|
||||
// we prevent the default so we don't trigger the anchor above
|
||||
event.preventDefault();
|
||||
onDelete?.(event);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChatActionButton = ({
|
||||
toolTipContent,
|
||||
icon,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
toolTipContent: string;
|
||||
icon: string;
|
||||
className?: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
btnTitle?: string;
|
||||
}) => {
|
||||
return (
|
||||
<WithTooltip tooltip={toolTipContent}>
|
||||
<button
|
||||
type="button"
|
||||
className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</WithTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import { motion, type Variants } from 'framer-motion';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
import { binDates } from './date-binning';
|
||||
import { useSearchFilter } from '~/lib/hooks/useSearchFilter';
|
||||
|
||||
const menuVariants = {
|
||||
closed: {
|
||||
@@ -33,13 +33,18 @@ const menuVariants = {
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
|
||||
export function Menu() {
|
||||
const { duplicateCurrentChat } = useChatHistory();
|
||||
export const Menu = () => {
|
||||
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||
|
||||
const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
|
||||
items: list,
|
||||
searchFields: ['description'],
|
||||
});
|
||||
|
||||
const loadEntries = useCallback(() => {
|
||||
if (db) {
|
||||
getAll(db)
|
||||
@@ -102,7 +107,6 @@ export function Menu() {
|
||||
|
||||
const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
|
||||
event.preventDefault();
|
||||
|
||||
setDialogContent({ type: 'delete', item });
|
||||
};
|
||||
|
||||
@@ -117,11 +121,11 @@ export function Menu() {
|
||||
initial="closed"
|
||||
animate={open ? 'open' : 'closed'}
|
||||
variants={menuVariants}
|
||||
className="flex 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"
|
||||
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="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4">
|
||||
<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"
|
||||
@@ -130,11 +134,26 @@ export function Menu() {
|
||||
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"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
onChange={handleSearchChange}
|
||||
aria-label="Search chats"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
|
||||
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
|
||||
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
|
||||
<div className="flex-1 overflow-auto pl-4 pr-5 pb-5">
|
||||
{filteredList.length === 0 && (
|
||||
<div className="pl-2 text-bolt-elements-textTertiary">
|
||||
{list.length === 0 ? 'No previous conversations' : 'No matches found'}
|
||||
</div>
|
||||
)}
|
||||
<DialogRoot open={dialogContent !== null}>
|
||||
{binDates(list).map(({ category, items }) => (
|
||||
{binDates(filteredList).map(({ category, items }) => (
|
||||
<div key={category} className="mt-4 first:mt-0 space-y-1">
|
||||
<div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
|
||||
{category}
|
||||
@@ -143,6 +162,7 @@ export function Menu() {
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
exportChat={exportChat}
|
||||
onDelete={(event) => handleDeleteClick(event, item)}
|
||||
onDuplicate={() => handleDuplicate(item.id)}
|
||||
/>
|
||||
@@ -186,4 +206,4 @@ export function Menu() {
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
73
app/components/ui/Tooltip.tsx
Normal file
73
app/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
interface TooltipProps {
|
||||
tooltip: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
sideOffset?: number;
|
||||
className?: string;
|
||||
arrowClassName?: string;
|
||||
tooltipStyle?: React.CSSProperties;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
maxWidth?: number;
|
||||
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
|
||||
className={`
|
||||
fill-bolt-elements-background-depth-3
|
||||
${arrowClassName}
|
||||
`}
|
||||
width={12}
|
||||
height={6}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default WithTooltip;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
type EditorDocument,
|
||||
@@ -9,21 +9,17 @@ import {
|
||||
type OnSaveCallback as OnEditorSave,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { shortcutEventEmitter } from '~/lib/hooks';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { logger, renderLogger } from '~/utils/logger';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { isMobile } from '~/utils/mobile';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTree } from './FileTree';
|
||||
import { Terminal, type TerminalRef } from './terminal/Terminal';
|
||||
import React from 'react';
|
||||
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
|
||||
interface EditorPanelProps {
|
||||
files?: FileMap;
|
||||
@@ -38,8 +34,6 @@ interface EditorPanelProps {
|
||||
onFileReset?: () => void;
|
||||
}
|
||||
|
||||
const MAX_TERMINALS = 3;
|
||||
const DEFAULT_TERMINAL_SIZE = 25;
|
||||
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
|
||||
|
||||
const editorSettings: EditorSettings = { tabSize: 2 };
|
||||
@@ -62,13 +56,6 @@ export const EditorPanel = memo(
|
||||
const theme = useStore(themeStore);
|
||||
const showTerminal = useStore(workbenchStore.showTerminal);
|
||||
|
||||
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
|
||||
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const terminalToggledByShortcut = useRef(false);
|
||||
|
||||
const [activeTerminal, setActiveTerminal] = useState(0);
|
||||
const [terminalCount, setTerminalCount] = useState(1);
|
||||
|
||||
const activeFileSegments = useMemo(() => {
|
||||
if (!editorDocument) {
|
||||
return undefined;
|
||||
@@ -81,48 +68,6 @@ export const EditorPanel = memo(
|
||||
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
|
||||
}, [editorDocument, unsavedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
|
||||
terminalToggledByShortcut.current = true;
|
||||
});
|
||||
|
||||
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
|
||||
for (const ref of Object.values(terminalRefs.current)) {
|
||||
ref?.reloadStyles();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromEventEmitter();
|
||||
unsubscribeFromThemeStore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { current: terminal } = terminalPanelRef;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = terminal.isCollapsed();
|
||||
|
||||
if (!showTerminal && !isCollapsed) {
|
||||
terminal.collapse();
|
||||
} else if (showTerminal && isCollapsed) {
|
||||
terminal.resize(DEFAULT_TERMINAL_SIZE);
|
||||
}
|
||||
|
||||
terminalToggledByShortcut.current = false;
|
||||
}, [showTerminal]);
|
||||
|
||||
const addTerminal = () => {
|
||||
if (terminalCount < MAX_TERMINALS) {
|
||||
setTerminalCount(terminalCount + 1);
|
||||
setActiveTerminal(terminalCount);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
|
||||
@@ -181,116 +126,7 @@ export const EditorPanel = memo(
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel
|
||||
ref={terminalPanelRef}
|
||||
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
|
||||
minSize={10}
|
||||
collapsible
|
||||
onExpand={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(true);
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index == 0 ? (
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary':
|
||||
isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Bolt Terminal
|
||||
</button>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Terminal {terminalCount > 1 && index}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
|
||||
<IconButton
|
||||
className="ml-auto"
|
||||
icon="i-ph:caret-down"
|
||||
title="Close"
|
||||
size="md"
|
||||
onClick={() => workbenchStore.toggleTerminal(false)}
|
||||
/>
|
||||
</div>
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
if (index == 0) {
|
||||
logger.info('Starting bolt terminal');
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<TerminalTabs />
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@ export const FileTree = memo(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('text-sm', className ,'overflow-y-auto')}>
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@@ -65,6 +66,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
const files = useStore(workbenchStore.files);
|
||||
const selectedView = useStore(workbenchStore.currentView);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
const setSelectedView = (view: WorkbenchViewType) => {
|
||||
workbenchStore.currentView.set(view);
|
||||
};
|
||||
@@ -128,18 +131,20 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
className={classNames(
|
||||
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
|
||||
{
|
||||
'w-full': isSmallViewport,
|
||||
'left-0': showWorkbench && isSmallViewport,
|
||||
'left-[var(--workbench-left)]': showWorkbench,
|
||||
'left-[100%]': !showWorkbench,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 px-6">
|
||||
<div className="absolute inset-0 px-2 lg:px-6">
|
||||
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
|
||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<>
|
||||
<div className="flex overflow-y-auto">
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
@@ -165,29 +170,37 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
const repoName = prompt("Please enter a name for your new GitHub repository:", "bolt-generated-project");
|
||||
const repoName = prompt(
|
||||
'Please enter a name for your new GitHub repository:',
|
||||
'bolt-generated-project',
|
||||
);
|
||||
|
||||
if (!repoName) {
|
||||
alert("Repository name is required. Push to GitHub cancelled.");
|
||||
alert('Repository name is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
const githubUsername = prompt("Please enter your GitHub username:");
|
||||
|
||||
const githubUsername = prompt('Please enter your GitHub username:');
|
||||
|
||||
if (!githubUsername) {
|
||||
alert("GitHub username is required. Push to GitHub cancelled.");
|
||||
alert('GitHub username is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
const githubToken = prompt("Please enter your GitHub personal access token:");
|
||||
|
||||
const githubToken = prompt('Please enter your GitHub personal access token:');
|
||||
|
||||
if (!githubToken) {
|
||||
alert("GitHub token is required. Push to GitHub cancelled.");
|
||||
alert('GitHub token is required. Push to GitHub cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
|
||||
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:github-logo" />
|
||||
Push to GitHub
|
||||
</PanelHeaderButton>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
|
||||
@@ -16,71 +16,74 @@ export interface TerminalProps {
|
||||
className?: string;
|
||||
theme: Theme;
|
||||
readonly?: boolean;
|
||||
id: string;
|
||||
onTerminalReady?: (terminal: XTerm) => void;
|
||||
onTerminalResize?: (cols: number, rows: number) => void;
|
||||
}
|
||||
|
||||
export const Terminal = memo(
|
||||
forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
|
||||
const terminalElementRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm>();
|
||||
forwardRef<TerminalRef, TerminalProps>(
|
||||
({ className, theme, readonly, id, onTerminalReady, onTerminalResize }, ref) => {
|
||||
const terminalElementRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm>();
|
||||
|
||||
useEffect(() => {
|
||||
const element = terminalElementRef.current!;
|
||||
useEffect(() => {
|
||||
const element = terminalElementRef.current!;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
disableStdin: readonly,
|
||||
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Menlo, courier-new, courier, monospace',
|
||||
});
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
disableStdin: readonly,
|
||||
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Menlo, courier-new, courier, monospace',
|
||||
});
|
||||
|
||||
terminalRef.current = terminal;
|
||||
terminalRef.current = terminal;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(element);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(element);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
onTerminalResize?.(terminal.cols, terminal.rows);
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
onTerminalResize?.(terminal.cols, terminal.rows);
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
logger.info('Attach terminal');
|
||||
logger.debug(`Attach [${id}]`);
|
||||
|
||||
onTerminalReady?.(terminal);
|
||||
onTerminalReady?.(terminal);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
terminal.dispose();
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
terminal.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current!;
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current!;
|
||||
|
||||
// we render a transparent cursor in case the terminal is readonly
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
// we render a transparent cursor in case the terminal is readonly
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
|
||||
terminal.options.disableStdin = readonly;
|
||||
}, [theme, readonly]);
|
||||
terminal.options.disableStdin = readonly;
|
||||
}, [theme, readonly]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
reloadStyles: () => {
|
||||
const terminal = terminalRef.current!;
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
reloadStyles: () => {
|
||||
const terminal = terminalRef.current!;
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={className} ref={terminalElementRef} />;
|
||||
}),
|
||||
return <div className={className} ref={terminalElementRef} />;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
186
app/components/workbench/terminal/TerminalTabs.tsx
Normal file
186
app/components/workbench/terminal/TerminalTabs.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { Panel, type ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { shortcutEventEmitter } from '~/lib/hooks';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Terminal, type TerminalRef } from './Terminal';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('Terminal');
|
||||
|
||||
const MAX_TERMINALS = 3;
|
||||
export const DEFAULT_TERMINAL_SIZE = 25;
|
||||
|
||||
export const TerminalTabs = memo(() => {
|
||||
const showTerminal = useStore(workbenchStore.showTerminal);
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
|
||||
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const terminalToggledByShortcut = useRef(false);
|
||||
|
||||
const [activeTerminal, setActiveTerminal] = useState(0);
|
||||
const [terminalCount, setTerminalCount] = useState(1);
|
||||
|
||||
const addTerminal = () => {
|
||||
if (terminalCount < MAX_TERMINALS) {
|
||||
setTerminalCount(terminalCount + 1);
|
||||
setActiveTerminal(terminalCount);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { current: terminal } = terminalPanelRef;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = terminal.isCollapsed();
|
||||
|
||||
if (!showTerminal && !isCollapsed) {
|
||||
terminal.collapse();
|
||||
} else if (showTerminal && isCollapsed) {
|
||||
terminal.resize(DEFAULT_TERMINAL_SIZE);
|
||||
}
|
||||
|
||||
terminalToggledByShortcut.current = false;
|
||||
}, [showTerminal]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
|
||||
terminalToggledByShortcut.current = true;
|
||||
});
|
||||
|
||||
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
|
||||
for (const ref of Object.values(terminalRefs.current)) {
|
||||
ref?.reloadStyles();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromEventEmitter();
|
||||
unsubscribeFromThemeStore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
ref={terminalPanelRef}
|
||||
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
|
||||
minSize={10}
|
||||
collapsible
|
||||
onExpand={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(true);
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{index == 0 ? (
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary':
|
||||
isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Bolt Terminal
|
||||
</button>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Terminal {terminalCount > 1 && index}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
|
||||
<IconButton
|
||||
className="ml-auto"
|
||||
icon="i-ph:caret-down"
|
||||
title="Close"
|
||||
size="md"
|
||||
onClick={() => workbenchStore.toggleTerminal(false)}
|
||||
/>
|
||||
</div>
|
||||
{Array.from({ length: terminalCount + 1 }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
logger.debug(`Starting bolt terminal [${index}]`);
|
||||
|
||||
if (index == 0) {
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user