mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-22 16:06:50 +00:00
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 <willem.jiang@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
export * from "./api";
|
||||
export * from "./file-validation";
|
||||
export * from "./hooks";
|
||||
export * from "./prompt-input-files";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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<File | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user