mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
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>
164 lines
4.8 KiB
TypeScript
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;
|
|
}
|