Skip to content

Commit 8c64144

Browse files
authored
Merge pull request #332 from atrokhym/main
HIGH PRIORITY - Attach images to prompts
2 parents 7e18820 + 5adc0f6 commit 8c64144

File tree

12 files changed

+276
-52
lines changed

12 files changed

+276
-52
lines changed

app/components/chat/BaseChat.tsx

Lines changed: 125 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportCh
2222
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
2323
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
2424

25+
import FilePreview from './FilePreview';
26+
2527
// @ts-ignore TODO: Introduce proper types
2628
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2729
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
@@ -85,8 +87,11 @@ interface BaseChatProps {
8587
enhancePrompt?: () => void;
8688
importChat?: (description: string, messages: Message[]) => Promise<void>;
8789
exportChat?: () => void;
90+
uploadedFiles?: File[];
91+
setUploadedFiles?: (files: File[]) => void;
92+
imageDataList?: string[];
93+
setImageDataList?: (dataList: string[]) => void;
8894
}
89-
9095
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
9196
(
9297
{
@@ -96,20 +101,24 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
96101
showChat = true,
97102
chatStarted = false,
98103
isStreaming = false,
99-
enhancingPrompt = false,
100-
promptEnhanced = false,
101-
messages,
102-
input = '',
103104
model,
104105
setModel,
105106
provider,
106107
setProvider,
107-
sendMessage,
108+
input = '',
109+
enhancingPrompt,
108110
handleInputChange,
111+
promptEnhanced,
109112
enhancePrompt,
113+
sendMessage,
110114
handleStop,
111115
importChat,
112116
exportChat,
117+
uploadedFiles = [],
118+
setUploadedFiles,
119+
imageDataList = [],
120+
setImageDataList,
121+
messages,
113122
},
114123
ref,
115124
) => {
@@ -159,6 +168,58 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
159168
}
160169
};
161170

171+
const handleFileUpload = () => {
172+
const input = document.createElement('input');
173+
input.type = 'file';
174+
input.accept = 'image/*';
175+
176+
input.onchange = async (e) => {
177+
const file = (e.target as HTMLInputElement).files?.[0];
178+
179+
if (file) {
180+
const reader = new FileReader();
181+
182+
reader.onload = (e) => {
183+
const base64Image = e.target?.result as string;
184+
setUploadedFiles?.([...uploadedFiles, file]);
185+
setImageDataList?.([...imageDataList, base64Image]);
186+
};
187+
reader.readAsDataURL(file);
188+
}
189+
};
190+
191+
input.click();
192+
};
193+
194+
const handlePaste = async (e: React.ClipboardEvent) => {
195+
const items = e.clipboardData?.items;
196+
197+
if (!items) {
198+
return;
199+
}
200+
201+
for (const item of items) {
202+
if (item.type.startsWith('image/')) {
203+
e.preventDefault();
204+
205+
const file = item.getAsFile();
206+
207+
if (file) {
208+
const reader = new FileReader();
209+
210+
reader.onload = (e) => {
211+
const base64Image = e.target?.result as string;
212+
setUploadedFiles?.([...uploadedFiles, file]);
213+
setImageDataList?.([...imageDataList, base64Image]);
214+
};
215+
reader.readAsDataURL(file);
216+
}
217+
218+
break;
219+
}
220+
}
221+
};
222+
162223
const baseChat = (
163224
<div
164225
ref={ref}
@@ -276,17 +337,56 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
276337
)}
277338
</div>
278339
</div>
279-
340+
<FilePreview
341+
files={uploadedFiles}
342+
imageDataList={imageDataList}
343+
onRemove={(index) => {
344+
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
345+
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
346+
}}
347+
/>
280348
<div
281349
className={classNames(
282350
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
283351
)}
284352
>
285353
<textarea
286354
ref={textareaRef}
287-
className={
288-
'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'
289-
}
355+
className={classNames(
356+
'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',
357+
'transition-all duration-200',
358+
'hover:border-bolt-elements-focus',
359+
)}
360+
onDragEnter={(e) => {
361+
e.preventDefault();
362+
e.currentTarget.style.border = '2px solid #1488fc';
363+
}}
364+
onDragOver={(e) => {
365+
e.preventDefault();
366+
e.currentTarget.style.border = '2px solid #1488fc';
367+
}}
368+
onDragLeave={(e) => {
369+
e.preventDefault();
370+
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
371+
}}
372+
onDrop={(e) => {
373+
e.preventDefault();
374+
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
375+
376+
const files = Array.from(e.dataTransfer.files);
377+
files.forEach((file) => {
378+
if (file.type.startsWith('image/')) {
379+
const reader = new FileReader();
380+
381+
reader.onload = (e) => {
382+
const base64Image = e.target?.result as string;
383+
setUploadedFiles?.([...uploadedFiles, file]);
384+
setImageDataList?.([...imageDataList, base64Image]);
385+
};
386+
reader.readAsDataURL(file);
387+
}
388+
});
389+
}}
290390
onKeyDown={(event) => {
291391
if (event.key === 'Enter') {
292392
if (event.shiftKey) {
@@ -302,6 +402,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
302402
onChange={(event) => {
303403
handleInputChange?.(event);
304404
}}
405+
onPaste={handlePaste}
305406
style={{
306407
minHeight: TEXTAREA_MIN_HEIGHT,
307408
maxHeight: TEXTAREA_MAX_HEIGHT,
@@ -312,29 +413,36 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
312413
<ClientOnly>
313414
{() => (
314415
<SendButton
315-
show={input.length > 0 || isStreaming}
416+
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
316417
isStreaming={isStreaming}
317418
onClick={(event) => {
318419
if (isStreaming) {
319420
handleStop?.();
320421
return;
321422
}
322423

323-
sendMessage?.(event);
424+
if (input.length > 0 || uploadedFiles.length > 0) {
425+
sendMessage?.(event);
426+
}
324427
}}
325428
/>
326429
)}
327430
</ClientOnly>
328431
<div className="flex justify-between items-center text-sm p-4 pt-2">
329432
<div className="flex gap-1 items-center">
433+
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
434+
<div className="i-ph:paperclip text-xl"></div>
435+
</IconButton>
330436
<IconButton
331437
title="Enhance prompt"
332438
disabled={input.length === 0 || enhancingPrompt}
333-
className={classNames('transition-all', {
334-
'opacity-100!': enhancingPrompt,
335-
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
336-
promptEnhanced,
337-
})}
439+
className={classNames(
440+
'transition-all',
441+
enhancingPrompt ? 'opacity-100' : '',
442+
promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
443+
promptEnhanced ? 'pr-1.5' : '',
444+
promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
445+
)}
338446
onClick={() => enhancePrompt?.()}
339447
>
340448
{enhancingPrompt ? (

app/components/chat/Chat.client.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from
1212
import { description, useChatHistory } from '~/lib/persistence';
1313
import { chatStore } from '~/lib/stores/chat';
1414
import { workbenchStore } from '~/lib/stores/workbench';
15-
import { fileModificationsToHTML } from '~/utils/diff';
1615
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
1716
import { cubicEasingFn } from '~/utils/easings';
1817
import { createScopedLogger, renderLogger } from '~/utils/logger';
@@ -89,8 +88,10 @@ export const ChatImpl = memo(
8988
useShortcuts();
9089

9190
const textareaRef = useRef<HTMLTextAreaElement>(null);
92-
9391
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
92+
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
93+
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
94+
9495
const [model, setModel] = useState(() => {
9596
const savedModel = Cookies.get('selectedModel');
9697
return savedModel || DEFAULT_MODEL;
@@ -206,29 +207,55 @@ export const ChatImpl = memo(
206207
runAnimation();
207208

208209
if (fileModifications !== undefined) {
209-
const diff = fileModificationsToHTML(fileModifications);
210-
211210
/**
212211
* If we have file modifications we append a new user message manually since we have to prefix
213212
* the user input with the file modifications and we don't want the new user input to appear
214213
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
215214
* manually reset the input and we'd have to manually pass in file attachments. However, those
216215
* aren't relevant here.
217216
*/
218-
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
217+
append({
218+
role: 'user',
219+
content: [
220+
{
221+
type: 'text',
222+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
223+
},
224+
...imageDataList.map((imageData) => ({
225+
type: 'image',
226+
image: imageData,
227+
})),
228+
] as any, // Type assertion to bypass compiler check
229+
});
219230

220231
/**
221232
* After sending a new message we reset all modifications since the model
222233
* should now be aware of all the changes.
223234
*/
224235
workbenchStore.resetAllFileModifications();
225236
} else {
226-
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
237+
append({
238+
role: 'user',
239+
content: [
240+
{
241+
type: 'text',
242+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
243+
},
244+
...imageDataList.map((imageData) => ({
245+
type: 'image',
246+
image: imageData,
247+
})),
248+
] as any, // Type assertion to bypass compiler check
249+
});
227250
}
228251

229252
setInput('');
230253
Cookies.remove(PROMPT_COOKIE_KEY);
231254

255+
// Add file cleanup here
256+
setUploadedFiles([]);
257+
setImageDataList([]);
258+
232259
resetEnhancer();
233260

234261
textareaRef.current?.blur();
@@ -321,6 +348,10 @@ export const ChatImpl = memo(
321348
apiKeys,
322349
);
323350
}}
351+
uploadedFiles={uploadedFiles}
352+
setUploadedFiles={setUploadedFiles}
353+
imageDataList={imageDataList}
354+
setImageDataList={setImageDataList}
324355
/>
325356
);
326357
},
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from 'react';
2+
3+
interface FilePreviewProps {
4+
files: File[];
5+
imageDataList: string[];
6+
onRemove: (index: number) => void;
7+
}
8+
9+
const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
10+
if (!files || files.length === 0) {
11+
return null;
12+
}
13+
14+
return (
15+
<div className="flex flex-row overflow-x-auto -mt-2">
16+
{files.map((file, index) => (
17+
<div key={file.name + file.size} className="mr-2 relative">
18+
{imageDataList[index] && (
19+
<div className="relative pt-4 pr-4">
20+
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
21+
<button
22+
onClick={() => onRemove(index)}
23+
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"
24+
>
25+
<div className="i-ph:x w-3 h-3 text-gray-200" />
26+
</button>
27+
</div>
28+
)}
29+
</div>
30+
))}
31+
</div>
32+
);
33+
};
34+
35+
export default FilePreview;

app/components/chat/SendButton.client.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ interface SendButtonProps {
44
show: boolean;
55
isStreaming?: boolean;
66
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
7+
onImagesSelected?: (images: File[]) => void;
78
}
89

910
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
1011

11-
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
12+
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
1213
return (
1314
<AnimatePresence>
1415
{show ? (
@@ -30,4 +31,4 @@ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
3031
) : null}
3132
</AnimatePresence>
3233
);
33-
}
34+
};

0 commit comments

Comments
 (0)