hermes-agent/web/src/lib/chatImagePaste.ts
Brooklyn Nicholson 301acc9eaa fix(web): paste/drop images into dashboard Chat via HERMES_HOME/images
Dashboard Chat is an xterm mirror of a TUI inside the gateway, so
server-side clipboard.paste never sees the browser clipboard. Upload
pasted/dropped images to the profile's images/ dir (same place
clipboard.paste / image.attach use), then drive /image over the PTY.

Uses a dedicated /api/chat/image-upload endpoint (magic-byte check,
25MB cap, profile scope) instead of relative managed-files uploads that
400 on local dashboards without a locked root. Ctrl/Cmd+Shift+V also
tries clipboard.read() for images before falling back to text, since
preventDefault on that chord suppresses the DOM paste event.

Salvages #57912 (client composition + /image PTY drive) and folds in
#48563's upload endpoint + drop path.

Co-authored-by: bird <6666242+bird@users.noreply.github.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
2026-07-10 02:20:04 -05:00

164 lines
4.8 KiB
TypeScript

import { authedFetch } from "@/lib/api";
// Clipboard image MIME → file extension. Mirrors the set the TUI's /image
// attach path and the gateway's image sniffer accept.
const IMAGE_MIME_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
};
// Anthropic caps a single vision image at ~25 MB; reject earlier client-side
// with a clear message rather than round-tripping a doomed upload.
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
export interface ChatImageUploadResult {
/** Absolute path under HERMES_HOME/images the gateway wrote. */
path: string;
/** Byte size of the uploaded image. */
bytes: number;
/** Basename written on the server. */
name: string;
mime_type: string;
}
function imageFileKey(file: File): string {
return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`;
}
function addImageFile(files: File[], seen: Set<string>, file: File | null) {
if (!file || !file.type.startsWith("image/")) return;
const key = imageFileKey(file);
if (seen.has(key)) return;
seen.add(key);
files.push(file);
}
/** Pull every image file out of a DataTransfer (clipboard or drop). */
export function imageFilesFromTransfer(
data: DataTransfer | null,
): File[] {
if (!data) return [];
const files: File[] = [];
const seen = new Set<string>();
if (data.items?.length) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (item.kind === "file" && item.type.startsWith("image/")) {
addImageFile(files, seen, item.getAsFile());
}
}
}
if (data.files?.length) {
for (let i = 0; i < data.files.length; i++) {
addImageFile(files, seen, data.files[i]);
}
}
return files;
}
/** Pull the first image blob out of a DataTransfer, or null if none present. */
export function firstImageFromClipboard(
data: DataTransfer | null,
): File | null {
return imageFilesFromTransfer(data)[0] ?? null;
}
/** True when a drag payload may contain an image (for dragover preventDefault). */
export function transferMayContainImage(data: DataTransfer | null): boolean {
if (!data) return false;
if (data.items?.length) {
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
if (
item.kind === "file" &&
(!item.type || item.type.startsWith("image/"))
) {
return true;
}
}
return false;
}
if (data.files?.length) {
for (let i = 0; i < data.files.length; i++) {
if (data.files[i].type.startsWith("image/")) return true;
}
}
return false;
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () =>
reject(reader.error ?? new Error("image read failed"));
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
resolve(result);
} else {
reject(new Error("image read failed"));
}
};
reader.readAsDataURL(file);
});
}
/**
* Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the
* dedicated chat upload endpoint and return the absolute gateway path.
*
* The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the
* gateway. The container has no access to the browser's clipboard, so the
* server-side ``clipboard.paste`` path can never see a pasted image.
* Upload the bytes the browser already holds, then hand the path to the
* TUI's ``/image`` command.
*/
export async function uploadChatImage(
blob: Blob,
profile = "",
): Promise<ChatImageUploadResult> {
if (blob.size === 0) throw new Error("clipboard image is empty");
if (blob.size > MAX_IMAGE_BYTES) {
const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024));
throw new Error(`image too large (max ${mb} MB)`);
}
const mime = blob.type || "image/png";
const ext = IMAGE_MIME_EXT[mime] || "png";
const filename =
blob instanceof File && blob.name
? blob.name
: `clipboard.${ext}`;
const file =
blob instanceof File
? blob
: new File([blob], filename, { type: mime });
const dataUrl = await fileToDataUrl(file);
const qs = profile ? `?profile=${encodeURIComponent(profile)}` : "";
const res = await authedFetch(`/api/chat/image-upload${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data_url: dataUrl,
filename,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || `HTTP ${res.status}`);
}
const uploaded = (await res.json()) as ChatImageUploadResult;
if (!uploaded?.path) {
throw new Error("image upload did not return a path");
}
return uploaded;
}