diff --git a/env.d.ts b/env.d.ts index 97eef2f..4aaf474 100644 --- a/env.d.ts +++ b/env.d.ts @@ -4,6 +4,7 @@ declare namespace Cloudflare { interface Env { Chat: DurableObjectNamespace; AI: Ai; + FILE_BUCKET: R2Bucket; } } interface Env extends Cloudflare.Env {} diff --git a/src/app.tsx b/src/app.tsx index ca75615..cfe7ad8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -22,9 +22,12 @@ import { Robot, Sun, Trash, + X, + Paperclip, PaperPlaneTilt, Stop } 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 +35,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 +51,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" }); @@ -81,28 +94,6 @@ export default function Chat() { setAgentInput(e.target.value); }; - const handleAgentSubmit = async ( - e: React.FormEvent, - extraData: Record = {} - ) => { - e.preventDefault(); - if (!agentInput.trim()) return; - - const message = agentInput; - setAgentInput(""); - - // Send message to agent - await sendMessage( - { - role: "user", - parts: [{ type: "text", text: message }] - }, - { - body: extraData - } - ); - }; - const { messages: agentMessages, addToolResult, @@ -135,6 +126,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 +263,10 @@ export default function Chat() { Local time in different locations +
  • + + Uploading and discussing files +
  • @@ -249,11 +301,49 @@ export default function Chat() {
    + {m.parts + ?.filter((part) => part.type === "file") + .map((part, i) => ( +
    +
    + {part.mediaType?.startsWith("image/") ? ( + + Uploaded image + + ) : ( + + + + File + + + )} +
    +
    + ))} + {m.parts?.map((part, i) => { if (part.type === "text") { return ( - // biome-ignore lint/suspicious/noArrayIndexKey: immutable index -
    +
    { + onSubmit={async (e) => { e.preventDefault(); - handleAgentSubmit(e, { - annotations: { - hello: "world" + 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 } + } + + const messageParts: Array< + | { type: "text"; text: string } + | { type: "file"; url: string; mediaType: string; name?: string } + > = []; + + // Add text part if there's input + if (agentInput.trim()) { + messageParts.push({ type: "text", text: agentInput }); + } + + // Add file parts if there are uploaded attachments + uploadedAttachments.forEach((attachment) => { + messageParts.push({ + type: "file", + url: attachment.url, + mediaType: attachment.contentType || "application/octet-stream", + name: attachment.name + }); }); + + setAgentInput(""); + + await sendMessage( + { + role: "user", + parts: messageParts + }, + { + body: { + annotations: { + hello: "world" + } + } + } + ); 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" > + {attachedFiles.length > 0 && ( +
    + {attachedFiles.map((file) => ( +
    + {file.type.startsWith("image/") ? ( + {file.name} + URL.revokeObjectURL((e.target as HTMLImageElement).src) + } + /> + ) : ( + + {getFileExtension(file.name)} + + )} + +
    + ))} +
    + )}
    + +