-
Notifications
You must be signed in to change notification settings - Fork 35
feat: add image upload and multimodal chat support #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
83ce745
a303fcc
92e561d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,4 +19,4 @@ | |
| "titleBar.inactiveForeground": "#e7e7e799" | ||
| }, | ||
| "peacock.color": "#38b" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import React from "react"; | ||
|
|
||
| interface ImagePreviewItem { | ||
| id: string; | ||
| previewUrl: string; | ||
| mimeType: string; | ||
| } | ||
|
|
||
| export function ImagePreview({ | ||
| images, | ||
| onRemove, | ||
| }: { | ||
| images: ImagePreviewItem[]; | ||
| onRemove: (id: string) => void | Promise<void>; | ||
| }) { | ||
| if (!images || images.length === 0) return null; | ||
|
|
||
| return ( | ||
| <div className="flex flex-wrap gap-2"> | ||
| {images.map((img) => ( | ||
| <div | ||
| key={img.id} | ||
| className="border-light-decorative-00 dark:border-dark-decorative-00 relative h-16 w-16 overflow-hidden rounded border" | ||
| > | ||
| <img | ||
| src={img.previewUrl} | ||
| alt="attachment" | ||
| className="h-full w-full object-cover" | ||
| draggable={false} | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={() => onRemove(img.id)} | ||
| aria-label="Remove image" | ||
| className="absolute top-0 right-0 m-0.5 rounded bg-black/60 px-1 text-[10px] leading-4 font-semibold text-white hover:bg-black/80" | ||
| > | ||
| × | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default ImagePreview; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,17 @@ | ||
| import React, { memo } from "react"; | ||
| import React, { memo, useEffect, useState } from "react"; | ||
| import ReactMarkdown from "react-markdown"; | ||
| import StructuredMessage from "./StructuredMessage.js"; | ||
| import type { | ||
| ChatMessageDocument, | ||
| AiChatMessageDocument, | ||
| SystemChatMessageDocument, | ||
| UserChatMessageDocument, | ||
| ViewType, | ||
| } from "../types/chat.js"; | ||
| import { parseContent } from "../utils/segmentParser.js"; | ||
| import { useFireproof, type DocFileMeta } from "use-fireproof"; | ||
| import { getSessionDatabaseName } from "../utils/databaseManager.js"; | ||
| import { ImgFile } from "./SessionSidebar/ImgFile.js"; | ||
|
|
||
| interface MessageProps { | ||
| message: ChatMessageDocument; | ||
|
|
@@ -106,10 +110,90 @@ | |
| }, | ||
| ); | ||
|
|
||
| const UserImages = ({ | ||
| sessionId, | ||
| imageIds, | ||
| }: { | ||
| sessionId: string; | ||
| imageIds: string[]; | ||
| }) => { | ||
| const dbName = getSessionDatabaseName(sessionId); | ||
| const { database } = useFireproof(dbName); | ||
| const [files, setFiles] = useState<DocFileMeta[]>([]); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
| async function load() { | ||
| const result: DocFileMeta[] = []; | ||
| for (const id of imageIds) { | ||
| try { | ||
| const doc = await database.get(id); | ||
| const fpFiles = doc?._files || {}; | ||
| const key = "image" in fpFiles ? "image" : Object.keys(fpFiles)[0]; | ||
| const ref = key ? fpFiles[key] : undefined; | ||
| if (ref) { | ||
| const anyRef: any = ref as any; | ||
|
Check failure on line 135 in vibes.diy/pkg/app/components/Message.tsx
|
||
| if (typeof anyRef.file === "function") { | ||
| // Fireproof DocFileMeta | ||
| result.push(anyRef as DocFileMeta); | ||
| } else if (ref instanceof File) { | ||
| // Raw File stored in _files (dev/testing scenarios) | ||
| const wrapped: DocFileMeta = { | ||
| file: async () => ref, | ||
| type: ref.type || "application/octet-stream", | ||
| size: ref.size, | ||
| cid: undefined as any, | ||
| }; | ||
| result.push(wrapped); | ||
| } | ||
| } | ||
| } catch (err) { | ||
| console.warn(`Failed to load image ${id}:`, err); | ||
| } | ||
| } | ||
| if (!cancelled) { | ||
| setFiles(result); | ||
| } | ||
| } | ||
| void load(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [database, imageIds]); | ||
|
|
||
| if (files.length === 0) return null; | ||
|
|
||
| return ( | ||
| <div className="flex flex-wrap gap-2 mb-2"> | ||
| {files.map((file, index) => ( | ||
| <div | ||
| key={`${sessionId}-${imageIds[index]}`} | ||
| className="border-light-decorative-00 dark:border-dark-decorative-00 overflow-hidden rounded border" | ||
| > | ||
| <ImgFile | ||
| file={file} | ||
| alt="User attachment" | ||
| className="max-w-xs" | ||
| maxHeight="12rem" | ||
| /> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
| }; | ||
|
Comment on lines
+124
to
+183
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rendering attached images currently uses Recommend: (1) load images concurrently, (2) normalize Fireproof refs without SuggestionConsider refactoring
Example: const toDocFileMeta = async (ref: unknown): Promise<DocFileMeta | null> => {
if (!ref) return null;
if (typeof (ref as any).file === "function") {
return ref as DocFileMeta;
}
if (ref instanceof File) {
const f = ref;
return {
file: async () => f,
type: f.type || "application/octet-stream",
size: f.size,
cid: undefined as any,
} satisfies DocFileMeta;
}
return null;
};
const [files, setFiles] = useState<Array<{ id: string; meta: DocFileMeta }>>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const pairs = await Promise.all(
imageIds.map(async (id) => {
try {
const doc = await database.get(id);
const fpFiles = doc?._files || {};
const key = "image" in fpFiles ? "image" : Object.keys(fpFiles)[0];
const meta = await toDocFileMeta(key ? fpFiles[key] : undefined);
return meta ? { id, meta } : null;
} catch (err) {
console.warn(`Failed to load image ${id}:`, err);
return null;
}
}),
);
if (!cancelled) setFiles(pairs.filter(Boolean) as Array<{ id: string; meta: DocFileMeta }>);
})();
return () => {
cancelled = true;
};
}, [database, imageIds]);
// Render
<div className="flex flex-wrap gap-2 mb-2">
{files.map(({ id, meta }) => (
<div key={`${sessionId}-${id}`} className="border-light-decorative-00 dark:border-dark-decorative-00 overflow-hidden rounded border">
<ImgFile file={meta} alt="User attachment" className="max-w-xs" maxHeight="12rem" />
</div>
))}
</div>Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this refactor. |
||
|
|
||
| const UserMessage = memo(({ message }: { message: ChatMessageDocument }) => { | ||
| const userMessage = message as UserChatMessageDocument; | ||
| const hasImages = userMessage.images && userMessage.images.length > 0; | ||
| return ( | ||
| <div className="mb-4 flex flex-row justify-end px-4"> | ||
| <div className="bg-light-background-02 dark:bg-dark-decorative-00 text-light-primary dark:text-dark-primary max-w-[85%] rounded-xl px-5 py-3 shadow-md"> | ||
| {hasImages && ( | ||
| <UserImages | ||
| sessionId={userMessage.session_id} | ||
| imageIds={userMessage.images!} | ||
| /> | ||
| )} | ||
| <div className="prose prose-sm dark:prose-invert prose-ul:pl-5 prose-ul:list-disc prose-ol:pl-5 prose-ol:list-decimal prose-li:my-0 max-w-none"> | ||
| <ReactMarkdown>{message.text}</ReactMarkdown> | ||
| </div> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Drag-and-drop is only handled on the textarea. Dropping files just outside the textarea can trigger the browser’s default navigation (opening the image), which risks losing the current chat state. It’s safer to prevent default
dragover/dropon the container (or window) to avoid accidental navigations outside the intended drop zone.Suggestion
Add container-level (or window-level)
dragover/droppreventDefault handlers so drops outside the textarea don’t navigate away. For example:Alternatively, add a
useEffectto register window-leveldragover/droplisteners thatpreventDefaultwhile the component is mounted.Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CharlieHelps yes please container-level
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expand this to see my work.
ChatInput.tsxto confirm drag-and-drop andonDropFileswere previously scoped only to the textarea.onDropFilestype fromReact.DragEvent<HTMLTextAreaElement>toReact.DragEvent<HTMLElement>, supporting shared use by both container and textarea event handlers.onDragEnter,onDragOver,onDragLeave, andonDrophandlers on the container<div>, activatingpreventDefaultand usingsetIsDraggingfor visual state across the entire chat input area.onDropcallspreventDefault, routes valid file drops toonDropFiles, and reliably resetssetIsDragging, with no window/global listeners introduced.pnpm check—all tests, type checks, and formatting passed without errors related to drag-and-drop orChatInput.tsx.jchris/chat-imagesbranch on origin without errors.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushed container-level drag-and-drop handling to prevent navigation and route file drops to the existing flow.
Changes
Key snippet (container wrapper):
And the shared drop handler type adjustment:
Verification
Notes:
Re-requested review and assigned the PR to @jchris.