From 1c0051c1db23d9075cadc7ed47f678fd52bf847b Mon Sep 17 00:00:00 2001 From: luobo <44131846+who96@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:54:35 +0800 Subject: [PATCH] fix(frontend): keep prompt attachments from breaking before upload (#1833) * fix(frontend): preserve prompt attachment files during upload * fix(frontend): harden prompt attachment fallback and tests --------- Co-authored-by: Willem Jiang --- .../components/ai-elements/prompt-input.tsx | 27 ++-- frontend/src/core/threads/hooks.ts | 28 +--- frontend/src/core/uploads/index.ts | 1 + .../core/uploads/prompt-input-files.test.mjs | 150 ++++++++++++++++++ .../src/core/uploads/prompt-input-files.ts | 52 ++++++ 5 files changed, 225 insertions(+), 33 deletions(-) create mode 100644 frontend/src/core/uploads/prompt-input-files.test.mjs create mode 100644 frontend/src/core/uploads/prompt-input-files.ts diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index 015dd56fd..52a909cdd 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -34,10 +34,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import type { PromptInputFilePart } from "@/core/uploads"; import { splitUnsupportedUploadFiles } from "@/core/uploads"; import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; -import type { ChatStatus, FileUIPart } from "ai"; +import type { ChatStatus } from "ai"; import { ArrowUpIcon, ImageIcon, @@ -79,7 +80,7 @@ import { toast } from "sonner"; // ============================================================================ export type AttachmentsContext = { - files: (FileUIPart & { id: string })[]; + files: (PromptInputFilePart & { id: string })[]; add: (files: File[] | FileList) => void; remove: (id: string) => void; clear: () => void; @@ -159,7 +160,7 @@ export function PromptInputProvider({ // ----- attachments state (global when wrapped) const [attachmentFiles, setAttachmentFiles] = useState< - (FileUIPart & { id: string })[] + (PromptInputFilePart & { id: string })[] >([]); const fileInputRef = useRef(null); const openRef = useRef<() => void>(() => {}); @@ -178,6 +179,7 @@ export function PromptInputProvider({ url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, + file, })), ), ); @@ -285,7 +287,7 @@ export const usePromptInputAttachments = () => { }; export type PromptInputAttachmentProps = HTMLAttributes & { - data: FileUIPart & { id: string }; + data: PromptInputFilePart & { id: string }; className?: string; }; @@ -384,7 +386,7 @@ export type PromptInputAttachmentsProps = Omit< HTMLAttributes, "children" > & { - children: (attachment: FileUIPart & { id: string }) => ReactNode; + children: (attachment: PromptInputFilePart & { id: string }) => ReactNode; }; export function PromptInputAttachments({ @@ -439,7 +441,7 @@ export const PromptInputActionAddAttachments = ({ export type PromptInputMessage = { text: string; - files: FileUIPart[]; + files: PromptInputFilePart[]; }; export type PromptInputProps = Omit< @@ -489,7 +491,9 @@ export const PromptInput = ({ const formRef = useRef(null); // ----- Local attachments (only used when no provider) - const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const [items, setItems] = useState<(PromptInputFilePart & { id: string })[]>( + [], + ); const files = usingProvider ? controller.attachments.files : items; // Keep a ref to files for cleanup on unmount (avoids stale closure) @@ -557,7 +561,7 @@ export const PromptInput = ({ message: "Too many files. Some were not added.", }); } - const next: (FileUIPart & { id: string })[] = []; + const next: (PromptInputFilePart & { id: string })[] = []; for (const file of capped) { next.push({ id: nanoid(), @@ -565,6 +569,7 @@ export const PromptInput = ({ url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, + file, }); } return prev.concat(next); @@ -765,6 +770,10 @@ export const PromptInput = ({ // Convert blob URLs to data URLs asynchronously Promise.all( files.map(async ({ id, ...item }) => { + if (item.file instanceof File) { + // Downstream upload prep reads the preserved File directly. + return item; + } if (item.url && item.url.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); // If conversion failed, keep the original blob URL @@ -776,7 +785,7 @@ export const PromptInput = ({ return item; }), ) - .then((convertedFiles: FileUIPart[]) => { + .then((convertedFiles: PromptInputFilePart[]) => { try { const result = onSubmit({ text, files: convertedFiles }, event); diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index ef0cae060..fbcce0301 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -14,7 +14,7 @@ import type { FileInMessage } from "../messages/utils"; import type { LocalSettings } from "../settings"; import { useUpdateSubtask } from "../tasks/context"; import type { UploadedFileInfo } from "../uploads"; -import { uploadFiles } from "../uploads"; +import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import type { AgentThread, AgentThreadState } from "./types"; @@ -279,28 +279,9 @@ export function useThreadStream({ if (message.files && message.files.length > 0) { setIsUploading(true); try { - // Convert FileUIPart to File objects by fetching blob URLs - const filePromises = message.files.map(async (fileUIPart) => { - if (fileUIPart.url && fileUIPart.filename) { - try { - // Fetch the blob URL to get the file data - const response = await fetch(fileUIPart.url); - const blob = await response.blob(); - - // Create a File object from the blob - return new File([blob], fileUIPart.filename, { - type: fileUIPart.mediaType || blob.type, - }); - } catch (error) { - console.error( - `Failed to fetch file ${fileUIPart.filename}:`, - error, - ); - return null; - } - } - return null; - }); + const filePromises = message.files.map((fileUIPart) => + promptInputFilePartToFile(fileUIPart), + ); const conversionResults = await Promise.all(filePromises); const files = conversionResults.filter( @@ -346,7 +327,6 @@ export function useThreadStream({ }); } } catch (error) { - console.error("Failed to upload files:", error); const errorMessage = error instanceof Error ? error.message diff --git a/frontend/src/core/uploads/index.ts b/frontend/src/core/uploads/index.ts index 5f66e7dfe..fd7fba0d0 100644 --- a/frontend/src/core/uploads/index.ts +++ b/frontend/src/core/uploads/index.ts @@ -5,3 +5,4 @@ export * from "./api"; export * from "./file-validation"; export * from "./hooks"; +export * from "./prompt-input-files"; diff --git a/frontend/src/core/uploads/prompt-input-files.test.mjs b/frontend/src/core/uploads/prompt-input-files.test.mjs new file mode 100644 index 000000000..f6579539e --- /dev/null +++ b/frontend/src/core/uploads/prompt-input-files.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadModule() { + try { + return await import("./prompt-input-files.ts"); + } catch (error) { + return { error }; + } +} + +test("exports the prompt-input file conversion helper", async () => { + const loaded = await loadModule(); + + assert.ok( + !("error" in loaded), + loaded.error instanceof Error + ? loaded.error.message + : "prompt-input-files module is missing", + ); + assert.equal(typeof loaded.promptInputFilePartToFile, "function"); +}); + +test("reuses the original File when a prompt attachment already has one", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const file = new File(["hello"], "note.txt", { type: "text/plain" }); + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => { + throw new Error("fetch should not run when File is already present"); + }; + + try { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: file.name, + mediaType: file.type, + url: "blob:http://localhost:2026/stale-preview-url", + file, + }); + + assert.equal(converted, file); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reconstructs a File from a data URL when no original File is present", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + mediaType: "text/plain", + url: "data:text/plain;base64,aGVsbG8=", + }); + + assert.ok(converted); + assert.equal(converted.name, "note.txt"); + assert.equal(converted.type, "text/plain"); + assert.equal(await converted.text(), "hello"); +}); + +test("rewraps the original File when the prompt metadata changes", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const file = new File(["hello"], "note.txt", { type: "text/plain" }); + + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "renamed.txt", + mediaType: "text/markdown", + file, + }); + + assert.ok(converted); + assert.notEqual(converted, file); + assert.equal(converted.name, "renamed.txt"); + assert.equal(converted.type, "text/markdown"); + assert.equal(await converted.text(), "hello"); +}); + +test("returns null when upload preparation is missing required data", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + + const converted = await promptInputFilePartToFile({ + type: "file", + mediaType: "text/plain", + }); + + assert.equal(converted, null); +}); + +test("returns null when the URL fallback fetch fails", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const originalFetch = globalThis.fetch; + const originalWarn = console.warn; + const warnCalls = []; + + console.warn = (...args) => { + warnCalls.push(args); + }; + + globalThis.fetch = async () => { + throw new Error("network down"); + }; + + try { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + url: "blob:http://localhost:2026/missing-preview-url", + }); + + assert.equal(converted, null); + assert.equal(warnCalls.length, 1); + } finally { + globalThis.fetch = originalFetch; + console.warn = originalWarn; + } +}); + +test("returns null when the URL fallback fetch response is non-ok", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const originalFetch = globalThis.fetch; + const originalWarn = console.warn; + const warnCalls = []; + + console.warn = (...args) => { + warnCalls.push(args); + }; + + globalThis.fetch = async () => + new Response("missing", { + status: 404, + statusText: "Not Found", + }); + + try { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + url: "blob:http://localhost:2026/missing-preview-url", + }); + + assert.equal(converted, null); + assert.equal(warnCalls.length, 1); + } finally { + globalThis.fetch = originalFetch; + console.warn = originalWarn; + } +}); diff --git a/frontend/src/core/uploads/prompt-input-files.ts b/frontend/src/core/uploads/prompt-input-files.ts new file mode 100644 index 000000000..33157a209 --- /dev/null +++ b/frontend/src/core/uploads/prompt-input-files.ts @@ -0,0 +1,52 @@ +import type { FileUIPart } from "ai"; + +export type PromptInputFilePart = FileUIPart & { + // Transient submit-time handle to the original browser File; not serializable. + file?: File; +}; + +export async function promptInputFilePartToFile( + filePart: PromptInputFilePart, +): Promise { + if (filePart.file instanceof File) { + const filename = + typeof filePart.filename === "string" && filePart.filename.length > 0 + ? filePart.filename + : filePart.file.name; + const mediaType = + typeof filePart.mediaType === "string" && filePart.mediaType.length > 0 + ? filePart.mediaType + : filePart.file.type; + + if (filePart.file.name === filename && filePart.file.type === mediaType) { + return filePart.file; + } + + return new File([filePart.file], filename, { type: mediaType }); + } + + if (!filePart.url || !filePart.filename) { + return null; + } + + try { + const response = await fetch(filePart.url); + if (!response.ok) { + throw new Error( + `HTTP ${response.status} while fetching fallback file URL`, + ); + } + const blob = await response.blob(); + + return new File([blob], filePart.filename, { + type: filePart.mediaType || blob.type, + }); + } catch (error) { + console.warn("promptInputFilePartToFile: fetch fallback failed", { + error, + url: filePart.url, + filename: filePart.filename, + }); + return null; + } +}