From 63cba68b50b5bcf802435a09348df933f71cd5cd Mon Sep 17 00:00:00 2001 From: Dustin Loring Date: Wed, 15 Jan 2025 13:41:51 -0500 Subject: [PATCH] feat: image support add the ability to add images to your prompt --- app/components/chat/BaseChat.tsx | 32 +++++++++++++++ app/components/chat/Chat.client.tsx | 62 ++++++++++++++++++----------- app/components/chat/FilePreview.tsx | 35 ++++++++++++++++ app/components/chat/UserMessage.tsx | 27 ++++++++++++- package.json | 4 +- pnpm-lock.yaml | 12 +++--- 6 files changed, 139 insertions(+), 33 deletions(-) create mode 100644 app/components/chat/FilePreview.tsx diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index e8133db..f2418e6 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -7,6 +7,7 @@ import { Workbench } from '~/components/workbench/Workbench.client'; import { classNames } from '~/utils/classNames'; import { Messages } from './Messages.client'; import { SendButton } from './SendButton.client'; +import FilePreview from './FilePreview'; import styles from './BaseChat.module.scss'; @@ -21,10 +22,14 @@ interface BaseChatProps { enhancingPrompt?: boolean; promptEnhanced?: boolean; input?: string; + files?: File[]; + imageDataList?: string[]; handleStop?: () => void; sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; + onFileUpload?: (files: FileList) => void; + onRemoveFile?: (index: number) => void; } const EXAMPLE_PROMPTS = [ @@ -54,6 +59,10 @@ export const BaseChat = React.forwardRef( handleInputChange, enhancePrompt, handleStop, + files, + imageDataList, + onFileUpload, + onRemoveFile, }, ref, ) => { @@ -173,6 +182,20 @@ export const BaseChat = React.forwardRef( )} + onFileUpload?.(e.target.files!)} + /> + document.getElementById('image-upload')?.click()} + > +
+
{input.length > 3 ? (
@@ -180,6 +203,15 @@ export const BaseChat = React.forwardRef(
) : null} + {files && files.length > 0 && ( +
+ {})} + /> +
+ )}
{/* Ghost Element */}
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index dff7598..8a69a61 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -68,6 +68,8 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp useShortcuts(); const textareaRef = useRef(null); + const [files, setFiles] = useState([]); + const [imageDataList, setImageDataList] = useState([]); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); @@ -146,6 +148,24 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp setChatStarted(true); }; + const handleFileUpload = (fileList: FileList) => { + const newFiles = Array.from(fileList); + setFiles((prevFiles) => [...prevFiles, ...newFiles]); + + newFiles.forEach((file) => { + const reader = new FileReader(); + reader.onloadend = () => { + setImageDataList((prev) => [...prev, reader.result as string]); + }; + reader.readAsDataURL(file); + }); + }; + + const handleRemoveFile = (index: number) => { + setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); + setImageDataList((prevList) => prevList.filter((_, i) => i !== index)); + }; + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { const _input = messageInput || input; @@ -153,13 +173,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp 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(); @@ -170,29 +183,28 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp 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 - * 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: `${diff}\n\n${_input}` }); - - /** - * 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: _input }); + append({ + role: 'user', + content: [ + { + type: 'text', + text: `${_input}`, + }, + ...imageDataList.map((imageData) => ({ + type: 'image', + image: imageData, + })), + ] as any, + }); } setInput(''); - + setFiles([]); + setImageDataList([]); resetEnhancer(); - textareaRef.current?.blur(); }; @@ -229,6 +241,10 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp scrollTextArea(); }); }} + files={files} + imageDataList={imageDataList} + onFileUpload={handleFileUpload} + onRemoveFile={handleRemoveFile} /> ); }); diff --git a/app/components/chat/FilePreview.tsx b/app/components/chat/FilePreview.tsx new file mode 100644 index 0000000..a569c75 --- /dev/null +++ b/app/components/chat/FilePreview.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface FilePreviewProps { + files: File[]; + imageDataList: string[]; + onRemove: (index: number) => void; +} + +const FilePreview: React.FC = ({ files, imageDataList, onRemove }) => { + if (!files || files.length === 0) { + return null; + } + + return ( +
+ {files.map((file, index) => ( +
+ {imageDataList[index] && ( +
+ {file.name} + +
+ )} +
+ ))} +
+ ); +}; + +export default FilePreview; \ No newline at end of file diff --git a/app/components/chat/UserMessage.tsx b/app/components/chat/UserMessage.tsx index 2f4e1d5..1e32611 100644 --- a/app/components/chat/UserMessage.tsx +++ b/app/components/chat/UserMessage.tsx @@ -1,14 +1,37 @@ import { modificationsRegex } from '~/utils/diff'; import { Markdown } from './Markdown'; +interface MessageContent { + type: string; + text?: string; + image?: string; +} + interface UserMessageProps { - content: string; + content: string | MessageContent[]; } export function UserMessage({ content }: UserMessageProps) { return (
- {sanitizeUserMessage(content)} + {Array.isArray(content) ? ( +
+ {content.map((item, index) => { + if (item.type === 'text') { + return {sanitizeUserMessage(item.text || '')}; + } else if (item.type === 'image') { + return ( +
+ User uploaded +
+ ); + } + return null; + })} +
+ ) : ( + {sanitizeUserMessage(content)} + )}
); } diff --git a/package.json b/package.json index fa2d2d1..3121cae 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,6 @@ "@nanostores/react": "^0.7.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", - "@remix-run/cloudflare": "^2.15.2", - "@remix-run/cloudflare-pages": "^2.15.2", "@remix-run/react": "^2.15.2", "@types/file-saver": "^2.0.7", "@uiw/codemirror-theme-vscode": "^4.23.7", @@ -84,6 +82,8 @@ "devDependencies": { "@blitz/eslint-plugin": "0.1.3", "@cloudflare/workers-types": "^4.20250109.0", + "@remix-run/cloudflare": "^2.15.2", + "@remix-run/cloudflare-pages": "^2.15.2", "@remix-run/dev": "^2.15.2", "@types/diff": "^5.2.3", "@types/react": "^18.3.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01384b8..83da92c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,12 +77,6 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.4 version: 2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@remix-run/cloudflare': - specifier: ^2.15.2 - version: 2.15.2(@cloudflare/workers-types@4.20250109.0)(typescript@5.7.3) - '@remix-run/cloudflare-pages': - specifier: ^2.15.2 - version: 2.15.2(@cloudflare/workers-types@4.20250109.0)(typescript@5.7.3) '@remix-run/react': specifier: ^2.15.2 version: 2.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.3) @@ -189,6 +183,12 @@ importers: '@cloudflare/workers-types': specifier: ^4.20250109.0 version: 4.20250109.0 + '@remix-run/cloudflare': + specifier: ^2.15.2 + version: 2.15.2(@cloudflare/workers-types@4.20250109.0)(typescript@5.7.3) + '@remix-run/cloudflare-pages': + specifier: ^2.15.2 + version: 2.15.2(@cloudflare/workers-types@4.20250109.0)(typescript@5.7.3) '@remix-run/dev': specifier: ^2.15.2 version: 2.15.2(@remix-run/react@2.15.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.3))(@types/node@22.10.6)(sass@1.77.6)(typescript@5.7.3)(vite@5.4.11(@types/node@22.10.6)(sass@1.77.6))(wrangler@3.102.0(@cloudflare/workers-types@4.20250109.0))