feat(webui): support image uploads in composer and message bubbles
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { encodeImage, type EncodeFailure } from "@/lib/imageEncode";
|
||||
|
||||
/** Lifecycle stages of one attachment:
|
||||
*
|
||||
* - ``encoding`` — posted to the Worker; chip shows a spinner
|
||||
* - ``ready`` — ``dataUrl`` available; safe to submit
|
||||
* - ``error`` — validation / decode failure; chip shows inline error
|
||||
*/
|
||||
export type AttachmentStatus = "encoding" | "ready" | "error";
|
||||
|
||||
export interface AttachedImage {
|
||||
id: string;
|
||||
file: File;
|
||||
/** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` /
|
||||
* unmount. */
|
||||
previewUrl: string;
|
||||
status: AttachmentStatus;
|
||||
/** Populated when ``status === "ready"``. */
|
||||
dataUrl?: string;
|
||||
/** Size of the final encoded payload (base64 bytes decoded). */
|
||||
encodedBytes?: number;
|
||||
/** Whether the Worker re-encoded the image to hit the size budget. */
|
||||
normalized?: boolean;
|
||||
/** Human-readable validation / encoding error when ``status === "error"``. */
|
||||
error?: AttachmentError;
|
||||
}
|
||||
|
||||
/** Machine-readable rejection reasons surfaced as inline chip errors.
|
||||
*
|
||||
* Callers localize these via the ``composer.imageRejected.*`` i18n table. */
|
||||
export type AttachmentError =
|
||||
| "unsupported_type" // server whitelist excludes this MIME
|
||||
| "too_many_images" // per-message cap (4) reached before enqueue
|
||||
| "magic_mismatch" // extension lies about the real content
|
||||
| "decode_failed" // Worker couldn't decode / re-encode
|
||||
| "too_large" // even after normalization we exceed the budget
|
||||
| "io"; // file read failed at the browser layer
|
||||
|
||||
export const MAX_IMAGES_PER_MESSAGE = 4;
|
||||
|
||||
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
|
||||
const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
function uuid(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return (crypto as Crypto).randomUUID();
|
||||
}
|
||||
return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
|
||||
switch (reason) {
|
||||
case "invalid_mime":
|
||||
case "magic_mismatch":
|
||||
return "magic_mismatch";
|
||||
case "too_large_after_normalize":
|
||||
return "too_large";
|
||||
case "io":
|
||||
return "io";
|
||||
case "decode_failed":
|
||||
default:
|
||||
return "decode_failed";
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseAttachedImagesApi {
|
||||
images: AttachedImage[];
|
||||
/** Enqueue new files. Returns the list of rejected files so the caller can
|
||||
* surface inline errors. Files rejected client-side (wrong MIME, limit) are
|
||||
* *not* added to ``images`` — only recoverable encoding failures show up as
|
||||
* error chips. */
|
||||
enqueue: (files: Iterable<File>) => {
|
||||
rejected: Array<{ file: File; reason: AttachmentError }>;
|
||||
};
|
||||
remove: (id: string) => { nextFocusId: string | null };
|
||||
/** Revoke every staged blob URL and drop all attachments. Called after a
|
||||
* successful submit — the optimistic bubble holds onto an independent
|
||||
* ``data:`` URL so tearing down blob previews here is safe. */
|
||||
clear: () => void;
|
||||
/** ``true`` when at least one image is still encoding — Send should wait. */
|
||||
encoding: boolean;
|
||||
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
/** Manage the lifecycle of images attached to the Composer.
|
||||
*
|
||||
* Responsibilities in one place:
|
||||
* - validation (MIME whitelist, count cap)
|
||||
* - blob URL creation + revocation
|
||||
* - Worker orchestration
|
||||
* - focus bookkeeping so keyboard delete doesn't strand the user
|
||||
*/
|
||||
export function useAttachedImages(): UseAttachedImagesApi {
|
||||
const [images, setImages] = useState<AttachedImage[]>([]);
|
||||
// Ref mirror so ``enqueue`` can see the authoritative length when invoked
|
||||
// multiple times in a single tick (rapid file selection, drag of many
|
||||
// files, paste storms). ``state`` is stale for that second + call.
|
||||
const imagesRef = useRef<AttachedImage[]>([]);
|
||||
imagesRef.current = images;
|
||||
|
||||
const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => {
|
||||
setImages((prev) => {
|
||||
const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img));
|
||||
imagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(files: Iterable<File>) => {
|
||||
const rejected: Array<{ file: File; reason: AttachmentError }> = [];
|
||||
const toAdd: AttachedImage[] = [];
|
||||
let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length;
|
||||
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_MIMES.has(file.type)) {
|
||||
rejected.push({ file, reason: "unsupported_type" });
|
||||
continue;
|
||||
}
|
||||
if (slot <= 0) {
|
||||
rejected.push({ file, reason: "too_many_images" });
|
||||
continue;
|
||||
}
|
||||
slot -= 1;
|
||||
toAdd.push({
|
||||
id: uuid(),
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
status: "encoding",
|
||||
});
|
||||
}
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
const next = [...imagesRef.current, ...toAdd];
|
||||
imagesRef.current = next;
|
||||
setImages(next);
|
||||
// Fire the Worker after the commit so chips render first (good INP).
|
||||
for (const entry of toAdd) {
|
||||
queueMicrotask(() => {
|
||||
encodeImage(entry.file).then(
|
||||
(result) => {
|
||||
if (result.ok) {
|
||||
setEntry(entry.id, {
|
||||
status: "ready",
|
||||
dataUrl: result.dataUrl,
|
||||
encodedBytes: result.bytes,
|
||||
normalized: result.normalized,
|
||||
});
|
||||
} else {
|
||||
setEntry(entry.id, {
|
||||
status: "error",
|
||||
error: mapEncodeFailure(result.reason),
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
setEntry(entry.id, {
|
||||
status: "error",
|
||||
error: "decode_failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return { rejected };
|
||||
},
|
||||
[setEntry],
|
||||
);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
let nextFocusId: string | null = null;
|
||||
setImages((prev) => {
|
||||
const idx = prev.findIndex((img) => img.id === id);
|
||||
if (idx === -1) return prev;
|
||||
const target = prev[idx];
|
||||
try {
|
||||
URL.revokeObjectURL(target.previewUrl);
|
||||
} catch {
|
||||
// No-op: previewUrl revocation is best-effort.
|
||||
}
|
||||
const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)];
|
||||
imagesRef.current = next;
|
||||
// Prefer moving focus to the chip at the same index, else previous.
|
||||
const candidate = next[idx] ?? next[idx - 1];
|
||||
nextFocusId = candidate?.id ?? null;
|
||||
return next;
|
||||
});
|
||||
return { nextFocusId };
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setImages((prev) => {
|
||||
for (const img of prev) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// revoke is best-effort
|
||||
}
|
||||
}
|
||||
imagesRef.current = [];
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
|
||||
// under StrictMode double-invoke because revoked blob URLs are only
|
||||
// referenced from in-hook chip state, which is rebuilt on remount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const img of imagesRef.current) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// best-effort cleanup on unmount
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const encoding = images.some((img) => img.status === "encoding");
|
||||
const full = images.length >= MAX_IMAGES_PER_MESSAGE;
|
||||
|
||||
return { images, enqueue, remove, clear, encoding, full };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
/** Extract image ``File``s from a paste / drop event.
|
||||
*
|
||||
* Deliberate behaviour:
|
||||
* - Only items whose ``kind === "file"`` and ``type`` starts with
|
||||
* ``image/`` are returned; ``<img>`` tags inside HTML fragments are
|
||||
* ignored (defending against remote URL fetch + XSS surfaces).
|
||||
* - Plain text pasted alongside images is *not* consumed by this helper,
|
||||
* so the caller can still let the textarea receive it naturally.
|
||||
*/
|
||||
export function extractImageFilesFromPaste(
|
||||
event: ClipboardEvent | React.ClipboardEvent,
|
||||
): File[] {
|
||||
const clipboard = (event as ClipboardEvent).clipboardData
|
||||
?? (event as React.ClipboardEvent).clipboardData;
|
||||
if (!clipboard) return [];
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(clipboard.items)) {
|
||||
if (item.kind !== "file") continue;
|
||||
if (!item.type.startsWith("image/")) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */
|
||||
export function extractImageFilesFromDrop(
|
||||
event: DragEvent | React.DragEvent,
|
||||
): File[] {
|
||||
const dt = (event as DragEvent).dataTransfer
|
||||
?? (event as React.DragEvent).dataTransfer;
|
||||
if (!dt) return [];
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(dt.files)) {
|
||||
if (item.type.startsWith("image/")) files.push(item);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export interface UseClipboardAndDropApi {
|
||||
/** Whether a drag is currently hovering the drop zone (toggle dragover UI). */
|
||||
isDragging: boolean;
|
||||
onPaste: (
|
||||
event: React.ClipboardEvent,
|
||||
) => void;
|
||||
onDragEnter: (event: React.DragEvent) => void;
|
||||
onDragOver: (event: React.DragEvent) => void;
|
||||
onDragLeave: (event: React.DragEvent) => void;
|
||||
onDrop: (event: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
/** Wire paste + drag-and-drop to a callback.
|
||||
*
|
||||
* The hook owns ``isDragging`` state and the refcount that keeps it accurate
|
||||
* across nested ``dragenter`` / ``dragleave`` events (a known DOM gotcha: the
|
||||
* text cursor inside a textarea fires ``dragleave`` on entry, flicking the
|
||||
* highlight off otherwise). */
|
||||
export function useClipboardAndDrop(
|
||||
onImageFiles: (files: File[]) => void,
|
||||
): UseClipboardAndDropApi {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
|
||||
const onPaste = useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
const files = extractImageFilesFromPaste(event);
|
||||
if (files.length === 0) return;
|
||||
// Consume only when an image is actually present; plain-text paste still
|
||||
// reaches the textarea unmolested.
|
||||
event.preventDefault();
|
||||
onImageFiles(files);
|
||||
},
|
||||
[onImageFiles],
|
||||
);
|
||||
|
||||
const onDragEnter = useCallback((event: React.DragEvent) => {
|
||||
if (!Array.from(event.dataTransfer.types ?? []).includes("Files")) return;
|
||||
event.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
if (!Array.from(event.dataTransfer.types ?? []).includes("Files")) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback((event: React.DragEvent) => {
|
||||
if (!Array.from(event.dataTransfer.types ?? []).includes("Files")) return;
|
||||
event.preventDefault();
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
dragDepth.current = 0;
|
||||
setIsDragging(false);
|
||||
const files = extractImageFilesFromDrop(event);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
onImageFiles(files);
|
||||
},
|
||||
[onImageFiles],
|
||||
);
|
||||
|
||||
return { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop };
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type { InboundEvent, UIMessage } from "@/lib/types";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
InboundEvent,
|
||||
OutboundMedia,
|
||||
UIImage,
|
||||
UIMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface StreamBuffer {
|
||||
/** ID of the assistant message currently receiving deltas. */
|
||||
@@ -16,24 +22,52 @@ interface StreamBuffer {
|
||||
* separately (e.g. via ``fetchSessionMessages``) since the server only replays
|
||||
* live events.
|
||||
*/
|
||||
/** Payload passed to ``send`` when the user attaches one or more images.
|
||||
*
|
||||
* ``media`` is handed to the wire client verbatim; ``preview`` powers the
|
||||
* optimistic user bubble (blob URLs so the preview appears before the server
|
||||
* acks the frame). Keeping the two separate lets the bubble re-use the local
|
||||
* blob URL even after the server persists the file under a different name. */
|
||||
export interface SendImage {
|
||||
media: OutboundMedia;
|
||||
preview: UIImage;
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
chatId: string | null,
|
||||
initialMessages: UIMessage[] = [],
|
||||
): {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
send: (content: string) => void;
|
||||
send: (content: string, images?: SendImage[]) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||
/** Latest transport-level fault raised since the last ``dismissStreamError``.
|
||||
* ``null`` when there is nothing to show. */
|
||||
streamError: StreamError | null;
|
||||
/** Clear the current ``streamError`` (e.g. after the user dismisses the
|
||||
* notification or starts a fresh action). */
|
||||
dismissStreamError: () => void;
|
||||
} {
|
||||
const { client } = useClient();
|
||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
|
||||
// Reset local state when switching chats.
|
||||
useEffect(() => {
|
||||
return client.onError((err) => setStreamError(err));
|
||||
}, [client]);
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
// Reset local state when switching chats. ``streamError`` is scoped to the
|
||||
// send that triggered it, so a chat swap should wipe it out: a stale
|
||||
// "Message too large" banner on a freshly-opened chat-B would confuse the
|
||||
// user about which send actually failed (and in which chat).
|
||||
useEffect(() => {
|
||||
setMessages(initialMessages);
|
||||
setIsStreaming(false);
|
||||
setStreamError(null);
|
||||
buffer.current = null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId]);
|
||||
@@ -145,8 +179,14 @@ export function useNanobotStream(
|
||||
}, [chatId, client]);
|
||||
|
||||
const send = useCallback(
|
||||
(content: string) => {
|
||||
if (!chatId || !content.trim()) return;
|
||||
(content: string, images?: SendImage[]) => {
|
||||
if (!chatId) return;
|
||||
const hasImages = !!images && images.length > 0;
|
||||
// Text is optional when images are attached — the agent will still see
|
||||
// the image blocks via ``media`` paths.
|
||||
if (!hasImages && !content.trim()) return;
|
||||
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -154,12 +194,21 @@ export function useNanobotStream(
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
]);
|
||||
client.sendMessage(chatId, content);
|
||||
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
},
|
||||
[chatId, client],
|
||||
);
|
||||
|
||||
return { messages, isStreaming, send, setMessages };
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
send,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,12 +123,25 @@ export function useSessionHistory(key: string | null): {
|
||||
const ui: UIMessage[] = body.messages.flatMap((m, idx) => {
|
||||
if (m.role !== "user" && m.role !== "assistant") return [];
|
||||
if (typeof m.content !== "string") return [];
|
||||
// Hydrate signed media URLs into the bubble's ``images`` slot so
|
||||
// historical user turns render real previews (the live-send path
|
||||
// uses data URLs; both shapes converge on the same ``UIImage``).
|
||||
const images =
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.media_urls) &&
|
||||
m.media_urls.length > 0
|
||||
? m.media_urls.map((mu) => ({
|
||||
url: mu.url,
|
||||
name: mu.name,
|
||||
}))
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id: `hist-${idx}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(),
|
||||
...(images ? { images } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user