feat(webui): support document attachments with ingress safeguards (#4771)
* feat: support document attachments in webui * fix(webui): normalize document attachment MIME * refactor(webui): move attachment policy out of channel * fix(webui): reject oversized attachments before send * fix(webui): align Portuguese attachment errors * refactor(webui): separate ingress and transport limits * fix(webui): reject malformed attachment payloads
This commit is contained in:
@@ -1,21 +1,24 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { encodeImage, type EncodeFailure } from "@/lib/imageEncode";
|
||||
import type { WebUIIngressLimits } from "@/lib/types";
|
||||
|
||||
/** Lifecycle stages of one attachment:
|
||||
*
|
||||
* - ``encoding`` — posted to the Worker; chip shows a spinner
|
||||
* - ``encoding`` — posted to the Worker / read from disk; 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 type AttachmentKind = "image" | "file";
|
||||
|
||||
export interface AttachedImage {
|
||||
export interface AttachedAttachment {
|
||||
id: string;
|
||||
kind: AttachmentKind;
|
||||
file: File;
|
||||
/** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` /
|
||||
* unmount. */
|
||||
previewUrl: string;
|
||||
previewUrl?: string;
|
||||
status: AttachmentStatus;
|
||||
/** Populated when ``status === "ready"``. */
|
||||
dataUrl?: string;
|
||||
@@ -27,37 +30,131 @@ export interface AttachedImage {
|
||||
error?: AttachmentError;
|
||||
}
|
||||
|
||||
export interface RestoredReadyImage {
|
||||
export type AttachedImage = AttachedAttachment;
|
||||
|
||||
export interface RestoredReadyAttachment {
|
||||
dataUrl: string;
|
||||
name?: string;
|
||||
kind?: AttachmentKind;
|
||||
}
|
||||
|
||||
export type RestoredReadyImage = RestoredReadyAttachment;
|
||||
|
||||
/** 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
|
||||
| "empty_file" // backend data-URL decoder rejects empty payloads
|
||||
| "too_many_attachments" // per-message cap (4) reached before enqueue
|
||||
| "total_too_large" // decoded attachments exceed the business-policy total
|
||||
| "transport_too_large" // projected JSON frame exceeds the transport guard
|
||||
| "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;
|
||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
||||
export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE;
|
||||
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
||||
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
|
||||
const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
|
||||
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const DOCUMENT_MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
||||
[".pdf", "application/pdf"],
|
||||
[".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
||||
[".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
||||
[".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
|
||||
[".txt", "text/plain"],
|
||||
[".md", "text/markdown"],
|
||||
[".csv", "text/csv"],
|
||||
[".json", "application/json"],
|
||||
[".xml", "application/xml"],
|
||||
[".html", "text/html"],
|
||||
[".htm", "text/html"],
|
||||
[".log", "text/plain"],
|
||||
[".yaml", "application/yaml"],
|
||||
[".yml", "application/yaml"],
|
||||
[".toml", "application/toml"],
|
||||
[".ini", "text/plain"],
|
||||
[".cfg", "text/plain"],
|
||||
]);
|
||||
|
||||
const ACCEPTED_DOCUMENT_MIMES: ReadonlySet<string> = new Set(DOCUMENT_MIME_BY_EXTENSION.values());
|
||||
|
||||
export const ACCEPT_ATTR = [
|
||||
...ACCEPTED_IMAGE_MIMES,
|
||||
...ACCEPTED_DOCUMENT_MIMES,
|
||||
...DOCUMENT_MIME_BY_EXTENSION.keys(),
|
||||
].join(",");
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot < 0 ? "" : name.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
function mimeForFile(file: File): string {
|
||||
const byName = DOCUMENT_MIME_BY_EXTENSION.get(extensionOf(file.name));
|
||||
if (byName) return byName;
|
||||
if (!file.type || file.type === "application/octet-stream") {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
return file.type;
|
||||
}
|
||||
|
||||
function projectedDataUrlBytes(
|
||||
file: File,
|
||||
kind: AttachmentKind,
|
||||
maxFileBytes: number,
|
||||
): number {
|
||||
const prefixBytes = `data:${mimeForFile(file)};base64,`.length;
|
||||
const decodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size;
|
||||
return prefixBytes + 4 * Math.ceil(decodedBytes / 3);
|
||||
}
|
||||
|
||||
function positiveLimit(value: number | null | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function attachmentPayloadBudget(limits: WebUIIngressLimits | null | undefined): number | null {
|
||||
const maxFrameBytes = limits?.transport.max_frame_bytes;
|
||||
if (typeof maxFrameBytes !== "number" || !Number.isFinite(maxFrameBytes)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor(maxFrameBytes)
|
||||
- positiveLimit(limits?.message.max_text_bytes, 0)
|
||||
- positiveLimit(limits?.transport.envelope_reserve_bytes, 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptedAttachmentKind(file: File): AttachmentKind | null {
|
||||
if (DOCUMENT_MIME_BY_EXTENSION.has(extensionOf(file.name))) return "file";
|
||||
if (ACCEPTED_IMAGE_MIMES.has(file.type)) return "image";
|
||||
const mime = mimeForFile(file);
|
||||
if (ACCEPTED_DOCUMENT_MIMES.has(mime)) return "file";
|
||||
return null;
|
||||
}
|
||||
|
||||
function dataUrlMime(dataUrl: string): string {
|
||||
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
|
||||
return match?.[1] || "image/png";
|
||||
}
|
||||
|
||||
function kindFromDataUrl(dataUrl: string): AttachmentKind {
|
||||
return dataUrlMime(dataUrl).startsWith("image/") ? "image" : "file";
|
||||
}
|
||||
|
||||
function dataUrlToFile(dataUrl: string, name?: string): File {
|
||||
const mime = dataUrlMime(dataUrl);
|
||||
const fallbackName = `image.${mime.split("/")[1] || "png"}`;
|
||||
@@ -81,6 +178,40 @@ function uuid(): string {
|
||||
return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function bufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(
|
||||
null,
|
||||
bytes.subarray(i, i + chunk) as unknown as number[],
|
||||
);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function encodeFile(file: File, maxFileBytes: number): Promise<{
|
||||
ok: true;
|
||||
dataUrl: string;
|
||||
bytes: number;
|
||||
} | {
|
||||
ok: false;
|
||||
reason: AttachmentError;
|
||||
}> {
|
||||
if (file.size > maxFileBytes) return { ok: false, reason: "too_large" };
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
return {
|
||||
ok: true,
|
||||
dataUrl: `data:${mimeForFile(file)};base64,${bufferToBase64(buffer)}`,
|
||||
bytes: file.size,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, reason: "io" };
|
||||
}
|
||||
}
|
||||
|
||||
function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
|
||||
switch (reason) {
|
||||
case "invalid_mime":
|
||||
@@ -97,11 +228,11 @@ function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
|
||||
}
|
||||
|
||||
export interface UseAttachedImagesApi {
|
||||
images: AttachedImage[];
|
||||
images: AttachedAttachment[];
|
||||
/** 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. */
|
||||
* *not* added to ``images`` — only recoverable read/encoding failures show
|
||||
* up as error chips. */
|
||||
enqueue: (files: Iterable<File>) => {
|
||||
rejected: Array<{ file: File; reason: AttachmentError }>;
|
||||
};
|
||||
@@ -110,17 +241,21 @@ export interface UseAttachedImagesApi {
|
||||
* successful submit — the optimistic bubble holds onto an independent
|
||||
* ``data:`` URL so tearing down blob previews here is safe. */
|
||||
clear: () => void;
|
||||
/** Restore already-encoded images, e.g. a queued composer draft moving back
|
||||
* into the input. These entries are immediately sendable and use their
|
||||
* ``data:`` URL as a stable preview. */
|
||||
restoreReadyImages: (images: RestoredReadyImage[]) => void;
|
||||
/** ``true`` when at least one image is still encoding — Send should wait. */
|
||||
/** Restore already-encoded attachments, e.g. a queued composer draft moving
|
||||
* back into the input. These entries are immediately sendable and use image
|
||||
* ``data:`` URLs as stable previews. */
|
||||
restoreReadyImages: (images: RestoredReadyAttachment[]) => void;
|
||||
/** ``true`` when at least one attachment is still encoding — Send should wait. */
|
||||
encoding: boolean;
|
||||
/** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
|
||||
/** ``true`` when we've hit ``MAX_ATTACHMENTS_PER_MESSAGE``. */
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
/** Manage the lifecycle of images attached to the Composer.
|
||||
interface UseAttachedImagesOptions {
|
||||
ingressLimits?: WebUIIngressLimits | null;
|
||||
}
|
||||
|
||||
/** Manage the lifecycle of attachments in the Composer.
|
||||
*
|
||||
* Responsibilities in one place:
|
||||
* - validation (MIME whitelist, count cap)
|
||||
@@ -128,15 +263,29 @@ export interface UseAttachedImagesApi {
|
||||
* - Worker orchestration
|
||||
* - focus bookkeeping so keyboard delete doesn't strand the user
|
||||
*/
|
||||
export function useAttachedImages(): UseAttachedImagesApi {
|
||||
const [images, setImages] = useState<AttachedImage[]>([]);
|
||||
export function useAttachedImages({
|
||||
ingressLimits = null,
|
||||
}: UseAttachedImagesOptions = {}): UseAttachedImagesApi {
|
||||
const [images, setImages] = useState<AttachedAttachment[]>([]);
|
||||
const maxAttachments = positiveLimit(
|
||||
ingressLimits?.attachments.max_count,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
);
|
||||
const maxFileBytes = positiveLimit(
|
||||
ingressLimits?.attachments.max_file_bytes,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
);
|
||||
const maxTotalBytes = positiveLimit(
|
||||
ingressLimits?.attachments.max_total_bytes,
|
||||
MAX_TOTAL_ATTACHMENT_BYTES,
|
||||
);
|
||||
// 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[]>([]);
|
||||
const imagesRef = useRef<AttachedAttachment[]>([]);
|
||||
imagesRef.current = images;
|
||||
|
||||
const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => {
|
||||
const setEntry = useCallback((id: string, patch: Partial<AttachedAttachment>) => {
|
||||
setImages((prev) => {
|
||||
const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img));
|
||||
imagesRef.current = next;
|
||||
@@ -147,23 +296,60 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
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;
|
||||
const toAdd: AttachedAttachment[] = [];
|
||||
let slot = maxAttachments - imagesRef.current.length;
|
||||
const payloadBudget = attachmentPayloadBudget(ingressLimits);
|
||||
let projectedWireBytes = imagesRef.current.reduce(
|
||||
(total, image) => total + (
|
||||
image.dataUrl?.length
|
||||
?? projectedDataUrlBytes(image.file, image.kind, maxFileBytes)
|
||||
),
|
||||
0,
|
||||
);
|
||||
let projectedDecodedBytes = imagesRef.current.reduce(
|
||||
(total, image) => total + (
|
||||
image.encodedBytes
|
||||
?? (image.kind === "image" ? Math.min(image.file.size, maxFileBytes) : image.file.size)
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_MIMES.has(file.type)) {
|
||||
const kind = acceptedAttachmentKind(file);
|
||||
if (!kind) {
|
||||
rejected.push({ file, reason: "unsupported_type" });
|
||||
continue;
|
||||
}
|
||||
if (file.size === 0) {
|
||||
rejected.push({ file, reason: "empty_file" });
|
||||
continue;
|
||||
}
|
||||
if (kind === "file" && file.size > maxFileBytes) {
|
||||
rejected.push({ file, reason: "too_large" });
|
||||
continue;
|
||||
}
|
||||
if (slot <= 0) {
|
||||
rejected.push({ file, reason: "too_many_images" });
|
||||
rejected.push({ file, reason: "too_many_attachments" });
|
||||
continue;
|
||||
}
|
||||
const nextDecodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size;
|
||||
if (projectedDecodedBytes + nextDecodedBytes > maxTotalBytes) {
|
||||
rejected.push({ file, reason: "total_too_large" });
|
||||
continue;
|
||||
}
|
||||
const nextWireBytes = projectedDataUrlBytes(file, kind, maxFileBytes);
|
||||
if (payloadBudget !== null && projectedWireBytes + nextWireBytes > payloadBudget) {
|
||||
rejected.push({ file, reason: "transport_too_large" });
|
||||
continue;
|
||||
}
|
||||
slot -= 1;
|
||||
projectedDecodedBytes += nextDecodedBytes;
|
||||
projectedWireBytes += nextWireBytes;
|
||||
toAdd.push({
|
||||
id: uuid(),
|
||||
kind,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
...(kind === "image" ? { previewUrl: URL.createObjectURL(file) } : {}),
|
||||
status: "encoding",
|
||||
});
|
||||
}
|
||||
@@ -175,19 +361,24 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
// Fire the Worker after the commit so chips render first (good INP).
|
||||
for (const entry of toAdd) {
|
||||
queueMicrotask(() => {
|
||||
encodeImage(entry.file).then(
|
||||
const work = entry.kind === "image"
|
||||
? encodeImage(entry.file)
|
||||
: encodeFile(entry.file, maxFileBytes);
|
||||
work.then(
|
||||
(result) => {
|
||||
if (result.ok) {
|
||||
setEntry(entry.id, {
|
||||
status: "ready",
|
||||
dataUrl: result.dataUrl,
|
||||
encodedBytes: result.bytes,
|
||||
normalized: result.normalized,
|
||||
normalized: "normalized" in result ? result.normalized : false,
|
||||
});
|
||||
} else {
|
||||
setEntry(entry.id, {
|
||||
status: "error",
|
||||
error: mapEncodeFailure(result.reason),
|
||||
error: entry.kind === "image"
|
||||
? mapEncodeFailure(result.reason as EncodeFailure["reason"])
|
||||
: result.reason as AttachmentError,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -203,7 +394,7 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
}
|
||||
return { rejected };
|
||||
},
|
||||
[setEntry],
|
||||
[ingressLimits, maxAttachments, maxFileBytes, maxTotalBytes, setEntry],
|
||||
);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
@@ -212,10 +403,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
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.
|
||||
if (target.previewUrl) {
|
||||
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;
|
||||
@@ -230,10 +423,12 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
const clear = useCallback(() => {
|
||||
setImages((prev) => {
|
||||
for (const img of prev) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// revoke is best-effort
|
||||
if (img.previewUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// revoke is best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
imagesRef.current = [];
|
||||
@@ -241,16 +436,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => {
|
||||
const restoreReadyImages = useCallback((restored: RestoredReadyAttachment[]) => {
|
||||
const toRestore = restored
|
||||
.filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl)))
|
||||
.slice(0, MAX_IMAGES_PER_MESSAGE)
|
||||
.map((img): AttachedImage => {
|
||||
.filter((img) => acceptedAttachmentKind(dataUrlToFile(img.dataUrl, img.name)))
|
||||
.slice(0, maxAttachments)
|
||||
.map((img): AttachedAttachment => {
|
||||
const file = dataUrlToFile(img.dataUrl, img.name);
|
||||
const kind = img.kind ?? kindFromDataUrl(img.dataUrl);
|
||||
return {
|
||||
id: uuid(),
|
||||
kind,
|
||||
file,
|
||||
previewUrl: img.dataUrl,
|
||||
...(kind === "image" ? { previewUrl: img.dataUrl } : {}),
|
||||
status: "ready",
|
||||
dataUrl: img.dataUrl,
|
||||
encodedBytes: file.size,
|
||||
@@ -258,16 +455,18 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
});
|
||||
setImages((prev) => {
|
||||
for (const img of prev) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// revoke is best-effort
|
||||
if (img.previewUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// revoke is best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
imagesRef.current = toRestore;
|
||||
return toRestore;
|
||||
});
|
||||
}, []);
|
||||
}, [maxAttachments]);
|
||||
|
||||
// Final safety net: revoke any outstanding blob URLs on unmount. Safe
|
||||
// under StrictMode double-invoke because revoked blob URLs are only
|
||||
@@ -275,17 +474,19 @@ export function useAttachedImages(): UseAttachedImagesApi {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const img of imagesRef.current) {
|
||||
try {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
} catch {
|
||||
// best-effort cleanup on unmount
|
||||
if (img.previewUrl) {
|
||||
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;
|
||||
const full = images.length >= maxAttachments;
|
||||
|
||||
return { images, enqueue, remove, clear, restoreReadyImages, encoding, full };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
/** Extract image ``File``s from a paste / drop event.
|
||||
import { acceptedAttachmentKind } from "@/hooks/useAttachedImages";
|
||||
|
||||
/** Extract supported attachment ``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,
|
||||
* - Only items whose ``kind === "file"`` and match the Composer whitelist
|
||||
* are returned; HTML fragments are ignored (defending against remote URL
|
||||
* fetch + XSS surfaces).
|
||||
* - Plain text pasted alongside attachments is *not* consumed by this helper,
|
||||
* so the caller can still let the textarea receive it naturally.
|
||||
*/
|
||||
export function extractImageFilesFromPaste(
|
||||
@@ -18,14 +20,13 @@ export function extractImageFilesFromPaste(
|
||||
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);
|
||||
if (file && acceptedAttachmentKind(file)) files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */
|
||||
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
|
||||
export function extractImageFilesFromDrop(
|
||||
event: DragEvent | React.DragEvent,
|
||||
): File[] {
|
||||
@@ -34,7 +35,7 @@ export function extractImageFilesFromDrop(
|
||||
if (!dt) return [];
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(dt.files)) {
|
||||
if (item.type.startsWith("image/")) files.push(item);
|
||||
if (acceptedAttachmentKind(item)) files.push(item);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -67,8 +68,8 @@ export function useClipboardAndDrop(
|
||||
(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.
|
||||
// Consume only when an attachment is actually present; plain-text paste
|
||||
// still reaches the textarea unmolested.
|
||||
event.preventDefault();
|
||||
onImageFiles(files);
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
ToolProgressEvent,
|
||||
UIImage,
|
||||
UIMediaAttachment,
|
||||
UIFileEdit,
|
||||
UIMessage,
|
||||
UITurnPhase,
|
||||
@@ -464,15 +464,15 @@ function findFileEditTraceIndex(
|
||||
* separately (e.g. via ``fetchWebuiThread``) since the server only replays
|
||||
* live events.
|
||||
*/
|
||||
/** Payload passed to ``send`` when the user attaches one or more images.
|
||||
/** Payload passed to ``send`` when the user attaches one or more files.
|
||||
*
|
||||
* ``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 {
|
||||
* optimistic user bubble. Keeping the two separate lets the bubble re-use the
|
||||
* local data URL even after the server persists the file under a different
|
||||
* name. */
|
||||
export interface SendAttachment {
|
||||
media: OutboundMedia;
|
||||
preview: UIImage;
|
||||
preview: UIMediaAttachment;
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
@@ -520,7 +520,7 @@ export function useNanobotStream(
|
||||
runStartedAt: number | null;
|
||||
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
||||
goalState: GoalStateWsPayload | undefined;
|
||||
send: (content: string, images?: SendImage[], options?: SendOptions) => void;
|
||||
send: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
||||
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
stop: () => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||
@@ -1135,12 +1135,12 @@ export function useNanobotStream(
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||
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 hasAttachments = !!images && images.length > 0;
|
||||
// Text is optional when files are attached — the agent will still see
|
||||
// them via ``media`` paths.
|
||||
if (!hasAttachments && !content.trim()) return;
|
||||
|
||||
const sideChannel = options?.sideChannel === true;
|
||||
const finalizeActiveTurn = options?.finalizeActiveTurn === true;
|
||||
@@ -1151,7 +1151,7 @@ export function useNanobotStream(
|
||||
}
|
||||
const turnId = crypto.randomUUID();
|
||||
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
const previews = hasAttachments ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => {
|
||||
if (!sideChannel || finalizeActiveTurn) {
|
||||
buffer.current = null;
|
||||
@@ -1171,14 +1171,14 @@ export function useNanobotStream(
|
||||
turnPhase: "user",
|
||||
turnSeq: 0,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
...(previews ? { media: previews } : {}),
|
||||
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
|
||||
...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (!sideChannel) setIsStreaming(true);
|
||||
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
||||
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
|
||||
const wireOptions = { ...options, turnId };
|
||||
delete wireOptions.sideChannel;
|
||||
delete wireOptions.finalizeActiveTurn;
|
||||
|
||||
Reference in New Issue
Block a user