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:
luobo
2026-04-04 14:54:35 +08:00
committed by GitHub
parent 144c9b2464
commit 1c0051c1db
5 changed files with 225 additions and 33 deletions
@@ -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<HTMLInputElement | null>(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<HTMLDivElement> & {
data: FileUIPart & { id: string };
data: PromptInputFilePart & { id: string };
className?: string;
};
@@ -384,7 +386,7 @@ export type PromptInputAttachmentsProps = Omit<
HTMLAttributes<HTMLDivElement>,
"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<HTMLFormElement | null>(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);