From 38b752270f6642a189eebf6f357e6bb7ba697e0f Mon Sep 17 00:00:00 2001 From: Nikita <190351315+riseandignite@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:27:04 +0800 Subject: [PATCH 1/6] feat(attachments): add attachments --- src/app.tsx | 293 +++++++++++++++++++++++++++++++++++++++++++++++-- src/server.ts | 79 ++++++++++++- src/utils.ts | 17 +++ wrangler.jsonc | 6 + 4 files changed, 383 insertions(+), 12 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index ca75615..4dc703e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -22,9 +22,13 @@ import { Robot, Sun, Trash, + X, + Paperclip, PaperPlaneTilt, Stop + PaperPlaneRight, } from "@phosphor-icons/react"; +import { getFileExtension } from "./utils"; // List of tools that require human confirmation // NOTE: this should match the tools that don't have execute functions in tools.ts @@ -32,6 +36,13 @@ const toolsRequiringConfirmation: (keyof typeof tools)[] = [ "getWeatherInformation" ]; +// Attachments type matching Vercel AI SDK +type Attachment = { + name?: string; + contentType?: string; + url: string; +}; + export default function Chat() { const [theme, setTheme] = useState<"dark" | "light">(() => { // Check localStorage first, default to dark if not found @@ -41,6 +52,9 @@ export default function Chat() { const [showDebug, setShowDebug] = useState(false); const [textareaHeight, setTextareaHeight] = useState("auto"); const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + const [attachedFiles, setAttachedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -135,6 +149,63 @@ export default function Chat() { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }; + const handleFileSelect = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files) { + const newFiles = Array.from(event.target.files); + setAttachedFiles((prevFiles) => { + const existingFileNames = new Set(prevFiles.map((f) => f.name)); + const uniqueNewFiles = newFiles.filter( + (f) => !existingFileNames.has(f.name) + ); + return [...prevFiles, ...uniqueNewFiles]; + }); + event.target.value = ""; // Clear input to allow selecting the same file again + } + }; + + const removeFile = (fileName: string) => { + setAttachedFiles((prevFiles) => + prevFiles.filter((file) => file.name !== fileName) + ); + }; + + const uploadFiles = async (files: File[]) => { + setIsUploading(true); + const formData = new FormData(); + files.forEach((file) => { + formData.append("files", file); + }); + + try { + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`File upload failed: ${response.statusText}`); + } + + const result: { + attachments: Attachment[]; + } = await response.json(); + + return result.attachments; + } catch (error) { + console.error("Error uploading files:", error); + alert( + `Failed to upload files: ${error instanceof Error ? error.message : String(error)}` + ); + return []; + } finally { + setIsUploading(false); + } + }; + return (
@@ -215,6 +286,10 @@ export default function Chat() { Local time in different locations +
  • + + Uploading and discussing files +
  • @@ -252,8 +327,65 @@ export default function Chat() { {m.parts?.map((part, i) => { if (part.type === "text") { return ( - // biome-ignore lint/suspicious/noArrayIndexKey: immutable index -
    +
    + {m.experimental_attachments && + m.experimental_attachments.length > 0 && ( +
    + {m.experimental_attachments.map( + (file) => { + return ( +
    + {file.contentType?.startsWith( + "image/" + ) ? ( + + {file.name} + + ) : ( + + + + {file.name} + + + ( + {getFileExtension( + file.name ?? "" + )} + ) + + + )} +
    + ); + } + )} +
    + )} -

    p.type === "text" && p.text.trim() + ) || + (typeof m.content === "string" && + m.content.trim())) && ( +

    + {formatTime( + new Date(m.createdAt as unknown as string) + )} +

    + )} +

    { + onSubmit={async (e) => { e.preventDefault(); + if (!agentInput.trim() && attachedFiles.length === 0) return; + if (isUploading) return; + + let uploadedAttachments: Attachment[] = []; + + if (attachedFiles.length > 0) { + uploadedAttachments = await uploadFiles(attachedFiles); + console.log("uploadedAttachments", uploadedAttachments); + + // Clear local files only if upload resulted in some valid attachments + if ( + uploadedAttachments.length > 0 || + attachedFiles.length === 0 + ) { + setAttachedFiles([]); + } + + // If upload failed completely or returned no valid attachments, stop. + if ( + uploadedAttachments.length === 0 && + attachedFiles.length > 0 + ) { + console.warn( + "File upload failed or returned invalid data, submission stopped." + ); + + // Optionally alert the user here + return; // Stop submission + } + } + handleAgentSubmit(e, { annotations: { hello: "world" } + experimental_attachments: uploadedAttachments, }); setTextareaHeight("auto"); // Reset height after submission }} - className="p-3 bg-neutral-50 absolute bottom-0 left-0 right-0 z-10 border-t border-neutral-300 dark:border-neutral-800 dark:bg-neutral-900" + className="p-3 bg-neutral-100 dark:bg-neutral-950 absolute bottom-0 left-0 right-0 z-10 border-t border-neutral-300 dark:border-neutral-800" > + {attachedFiles.length > 0 && ( +

    + {attachedFiles.map((file) => ( +
    + {file.type.startsWith("image/") ? ( + {file.name} + URL.revokeObjectURL((e.target as HTMLImageElement).src) + } + /> + ) : ( + + {getFileExtension(file.name)} + + )} + +
    + ))} +
    + )}
    + +