Merge branch 'main' into github-import

This commit is contained in:
Anirban Kar
2024-12-06 18:54:06 +05:30
committed by GitHub
29 changed files with 868 additions and 135 deletions

View File

@@ -28,6 +28,7 @@ interface ArtifactProps {
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId];
@@ -47,6 +48,11 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
if (actions.length !== 0) {
const finished = !actions.find((action) => action.status !== 'complete');
setAllActionFinished(finished);
}
}, [actions]);
return (
@@ -59,6 +65,18 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
{artifact.type == 'bundled' && (
<>
<div className="p-4">
{allActionFinished ? (
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
) : (
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
)}
</div>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
</>
)}
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
@@ -66,7 +84,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{actions.length && (
{actions.length && artifact.type !== 'bundled' && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
@@ -83,7 +101,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
</AnimatePresence>
</div>
<AnimatePresence>
{showActions && actions.length > 0 && (
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
@@ -92,6 +110,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>

View File

@@ -23,44 +23,9 @@ import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButto
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
import GitCloneButton from './GitCloneButton';
// @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 flex-col sm:flex-row">
<select
value={provider?.name}
onChange={(e) => {
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: ProviderInfo) => (
<option key={provider.name} value={provider.name}>
{provider.name}
</option>
))}
</select>
<select
key={provider?.name}
value={model}
onChange={(e) => setModel(e.target.value)}
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)
.map((modelOption) => (
<option key={modelOption.name} value={modelOption.name}>
{modelOption.label}
</option>
))}
</select>
</div>
);
};
import FilePreview from './FilePreview';
import { ModelSelector } from '~/components/chat/ModelSelector';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -86,6 +51,10 @@ interface BaseChatProps {
enhancePrompt?: () => void;
importChat?: (description: string, messages: Message[]) => Promise<void>;
exportChat?: () => void;
uploadedFiles?: File[];
setUploadedFiles?: (files: File[]) => void;
imageDataList?: string[];
setImageDataList?: (dataList: string[]) => void;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -97,20 +66,24 @@ 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,
) => {
@@ -118,7 +91,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
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 {
@@ -141,8 +118,72 @@ 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();
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
const transcript = Array.from(event.results)
.map((result) => result[0])
.map((result) => result.transcript)
.join('');
setTranscript(transcript);
if (handleInputChange) {
const syntheticEvent = {
target: { value: transcript },
} as React.ChangeEvent<HTMLTextAreaElement>;
handleInputChange(syntheticEvent);
}
};
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error);
setIsListening(false);
};
setRecognition(recognition);
}
}, []);
const startListening = () => {
if (recognition) {
recognition.start();
setIsListening(true);
}
};
const stopListening = () => {
if (recognition) {
recognition.stop();
setIsListening(false);
}
};
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
if (sendMessage) {
sendMessage(event, messageInput);
if (recognition) {
recognition.abort(); // Stop current recognition
setTranscript(''); // Clear transcript
setIsListening(false);
// Clear the input by triggering handleInputChange with empty value
if (handleInputChange) {
const syntheticEvent = {
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>;
handleInputChange(syntheticEvent);
}
}
}
};
const updateApiKey = (provider: string, key: string) => {
try {
const updatedApiKeys = { ...apiKeys, [provider]: key };
@@ -160,6 +201,58 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
}
};
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}
@@ -276,7 +369,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
)}
</div>
</div>
<FilePreview
files={uploadedFiles}
imageDataList={imageDataList}
onRemove={(index) => {
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
}}
/>
<div
className={classNames(
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
@@ -284,9 +384,41 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
>
<textarea
ref={textareaRef}
className={
'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'
}
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) {
@@ -295,13 +427,19 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
event.preventDefault();
sendMessage?.(event);
if (isStreaming) {
handleStop?.();
return;
}
handleSendMessage?.(event);
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
onPaste={handlePaste}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
@@ -312,7 +450,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) {
@@ -320,21 +458,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
return;
}
sendMessage?.(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 ? (
@@ -349,6 +494,13 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</>
)}
</IconButton>
<SpeechRecognitionButton
isListening={isListening}
onStart={startListening}
onStop={stopListening}
disabled={isStreaming}
/>
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
</div>
{input.length > 3 ? (
@@ -368,7 +520,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<GitCloneButton importChat={importChat} />
</div>
)}
{!chatStarted && ExamplePrompts(sendMessage)}
{!chatStarted &&
ExamplePrompts((event, messageInput) => {
if (isStreaming) {
handleStop?.();
return;
}
handleSendMessage?.(event, messageInput);
})}
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div>

View File

@@ -12,7 +12,6 @@ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from
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, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
@@ -89,8 +88,10 @@ export const ChatImpl = memo(
useShortcuts();
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 [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL;
@@ -206,8 +207,6 @@ export const ChatImpl = memo(
runAnimation();
if (fileModifications !== undefined) {
const diff = fileModificationsToHTML(fileModifications);
/**
* 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
@@ -215,7 +214,19 @@ export const ChatImpl = memo(
* 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}` });
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
@@ -223,12 +234,28 @@ export const ChatImpl = memo(
*/
workbenchStore.resetAllFileModifications();
} else {
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
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();
@@ -321,6 +348,10 @@ export const ChatImpl = memo(
apiKeys,
);
}}
uploadedFiles={uploadedFiles}
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
/>
);
},

View 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;

View File

@@ -79,7 +79,7 @@ ${content}
role: 'assistant',
content: `I'll help you set up these files.${binaryFilesMessage}
<boltArtifact id="imported-files" title="Imported Files">
<boltArtifact id="imported-files" title="Imported Files" type="bundled">
${fileArtifacts.join('\n\n')}
</boltArtifact>`,
id: generateId(),

View File

@@ -0,0 +1,63 @@
import type { ProviderInfo } from '~/types/model';
import type { ModelInfo } from '~/utils/types';
interface ModelSelectorProps {
model?: string;
setModel?: (model: string) => void;
provider?: ProviderInfo;
setProvider?: (provider: ProviderInfo) => void;
modelList: ModelInfo[];
providerList: ProviderInfo[];
apiKeys: Record<string, string>;
}
export const ModelSelector = ({
model,
setModel,
provider,
setProvider,
modelList,
providerList,
}: ModelSelectorProps) => {
return (
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
<select
value={provider?.name ?? ''}
onChange={(e) => {
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
if (newProvider && setProvider) {
setProvider(newProvider);
}
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
if (firstModel && setModel) {
setModel(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: ProviderInfo) => (
<option key={provider.name} value={provider.name}>
{provider.name}
</option>
))}
</select>
<select
key={provider?.name}
value={model}
onChange={(e) => setModel?.(e.target.value)}
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)
.map((modelOption) => (
<option key={modelOption.name} value={modelOption.name}>
{modelOption.label}
</option>
))}
</select>
</div>
);
};

View File

@@ -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>
);
}
};

View File

@@ -0,0 +1,28 @@
import { IconButton } from '~/components/ui/IconButton';
import { classNames } from '~/utils/classNames';
import React from 'react';
export const SpeechRecognitionButton = ({
isListening,
onStart,
onStop,
disabled,
}: {
isListening: boolean;
onStart: () => void;
onStop: () => void;
disabled: boolean;
}) => {
return (
<IconButton
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" />}
</IconButton>
);
};

View File

@@ -2,26 +2,52 @@
* @ts-nocheck
* Preventing TS checks with files presented in the video for a better presentation.
*/
import { modificationsRegex } from '~/utils/diff';
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, '');
}