File size: 4,878 Bytes
6aa76ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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;
}