mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +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:
parent
144c9b2464
commit
1c0051c1db
@ -34,10 +34,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import type { PromptInputFilePart } from "@/core/uploads";
|
||||||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatStatus, FileUIPart } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@ -79,7 +80,7 @@ import { toast } from "sonner";
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type AttachmentsContext = {
|
export type AttachmentsContext = {
|
||||||
files: (FileUIPart & { id: string })[];
|
files: (PromptInputFilePart & { id: string })[];
|
||||||
add: (files: File[] | FileList) => void;
|
add: (files: File[] | FileList) => void;
|
||||||
remove: (id: string) => void;
|
remove: (id: string) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
@ -159,7 +160,7 @@ export function PromptInputProvider({
|
|||||||
|
|
||||||
// ----- attachments state (global when wrapped)
|
// ----- attachments state (global when wrapped)
|
||||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||||
(FileUIPart & { id: string })[]
|
(PromptInputFilePart & { id: string })[]
|
||||||
>([]);
|
>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const openRef = useRef<() => void>(() => {});
|
const openRef = useRef<() => void>(() => {});
|
||||||
@ -178,6 +179,7 @@ export function PromptInputProvider({
|
|||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
mediaType: file.type,
|
mediaType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
|
file,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -285,7 +287,7 @@ export const usePromptInputAttachments = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
data: FileUIPart & { id: string };
|
data: PromptInputFilePart & { id: string };
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -384,7 +386,7 @@ export type PromptInputAttachmentsProps = Omit<
|
|||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
"children"
|
"children"
|
||||||
> & {
|
> & {
|
||||||
children: (attachment: FileUIPart & { id: string }) => ReactNode;
|
children: (attachment: PromptInputFilePart & { id: string }) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PromptInputAttachments({
|
export function PromptInputAttachments({
|
||||||
@ -439,7 +441,7 @@ export const PromptInputActionAddAttachments = ({
|
|||||||
|
|
||||||
export type PromptInputMessage = {
|
export type PromptInputMessage = {
|
||||||
text: string;
|
text: string;
|
||||||
files: FileUIPart[];
|
files: PromptInputFilePart[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PromptInputProps = Omit<
|
export type PromptInputProps = Omit<
|
||||||
@ -489,7 +491,9 @@ export const PromptInput = ({
|
|||||||
const formRef = useRef<HTMLFormElement | null>(null);
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
// ----- Local attachments (only used when no provider)
|
// ----- 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;
|
const files = usingProvider ? controller.attachments.files : items;
|
||||||
|
|
||||||
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
// 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.",
|
message: "Too many files. Some were not added.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const next: (FileUIPart & { id: string })[] = [];
|
const next: (PromptInputFilePart & { id: string })[] = [];
|
||||||
for (const file of capped) {
|
for (const file of capped) {
|
||||||
next.push({
|
next.push({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
@ -565,6 +569,7 @@ export const PromptInput = ({
|
|||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
mediaType: file.type,
|
mediaType: file.type,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
|
file,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return prev.concat(next);
|
return prev.concat(next);
|
||||||
@ -765,6 +770,10 @@ export const PromptInput = ({
|
|||||||
// Convert blob URLs to data URLs asynchronously
|
// Convert blob URLs to data URLs asynchronously
|
||||||
Promise.all(
|
Promise.all(
|
||||||
files.map(async ({ id, ...item }) => {
|
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:")) {
|
if (item.url && item.url.startsWith("blob:")) {
|
||||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||||
// If conversion failed, keep the original blob URL
|
// If conversion failed, keep the original blob URL
|
||||||
@ -776,7 +785,7 @@ export const PromptInput = ({
|
|||||||
return item;
|
return item;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.then((convertedFiles: FileUIPart[]) => {
|
.then((convertedFiles: PromptInputFilePart[]) => {
|
||||||
try {
|
try {
|
||||||
const result = onSubmit({ text, files: convertedFiles }, event);
|
const result = onSubmit({ text, files: convertedFiles }, event);
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import type { FileInMessage } from "../messages/utils";
|
|||||||
import type { LocalSettings } from "../settings";
|
import type { LocalSettings } from "../settings";
|
||||||
import { useUpdateSubtask } from "../tasks/context";
|
import { useUpdateSubtask } from "../tasks/context";
|
||||||
import type { UploadedFileInfo } from "../uploads";
|
import type { UploadedFileInfo } from "../uploads";
|
||||||
import { uploadFiles } from "../uploads";
|
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||||
|
|
||||||
import type { AgentThread, AgentThreadState } from "./types";
|
import type { AgentThread, AgentThreadState } from "./types";
|
||||||
|
|
||||||
@ -279,28 +279,9 @@ export function useThreadStream({
|
|||||||
if (message.files && message.files.length > 0) {
|
if (message.files && message.files.length > 0) {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
// Convert FileUIPart to File objects by fetching blob URLs
|
const filePromises = message.files.map((fileUIPart) =>
|
||||||
const filePromises = message.files.map(async (fileUIPart) => {
|
promptInputFilePartToFile(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 conversionResults = await Promise.all(filePromises);
|
const conversionResults = await Promise.all(filePromises);
|
||||||
const files = conversionResults.filter(
|
const files = conversionResults.filter(
|
||||||
@ -346,7 +327,6 @@ export function useThreadStream({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload files:", error);
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
|
|||||||
@ -5,3 +5,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./file-validation";
|
export * from "./file-validation";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
|
export * from "./prompt-input-files";
|
||||||
|
|||||||
150
frontend/src/core/uploads/prompt-input-files.test.mjs
Normal file
150
frontend/src/core/uploads/prompt-input-files.test.mjs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
52
frontend/src/core/uploads/prompt-input-files.ts
Normal file
52
frontend/src/core/uploads/prompt-input-files.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user