diff --git a/README.md b/README.md index 6174db6..45dc0e5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A starter template for building AI chat agents on Cloudflare, powered by the [Agents SDK](https://developers.cloudflare.com/agents/). -Uses Workers AI (no API key required), with tools for weather, timezone detection, calculations with approval, and task scheduling. +Uses Workers AI (no API key required), with tools for weather, timezone detection, calculations with approval, task scheduling, and vision (image input). ## Quick start @@ -25,6 +25,7 @@ Try these prompts to see the different features: - **"What timezone am I in?"** — client-side tool (browser provides the answer) - **"Calculate 5000 \* 3"** — approval tool (asks you before running) - **"Remind me in 5 minutes to take a break"** — scheduling +- **Drop an image and ask "What's in this image?"** — vision (image understanding) ## Project structure @@ -39,6 +40,7 @@ src/ ## What's included - **AI Chat** — Streaming responses powered by Workers AI via `AIChatAgent` +- **Image input** — Drag-and-drop, paste, or click to attach images for vision-capable models - **Three tool patterns** — server-side auto-execute, client-side (browser), and human-in-the-loop approval - **Scheduling** — one-time, delayed, and recurring (cron) tasks - **Reasoning display** — shows model thinking as it streams, collapses when done diff --git a/package-lock.json b/package-lock.json index b7cc366..9d8d3e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "streamdown": "^2.2.0", - "workers-ai-provider": "^3.1.3", + "workers-ai-provider": "^3.1.4", "zod": "^4.3.6" }, "devDependencies": { @@ -7902,6 +7902,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -7917,9 +7918,9 @@ } }, "node_modules/workers-ai-provider": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/workers-ai-provider/-/workers-ai-provider-3.1.3.tgz", - "integrity": "sha512-CiNzAy6ELorgpB/fwNJVYTvZyxLY2IY2aILeshiMrM9fLo+ORGo4HMg3EwgUAUbVMOhIJjjb+cFVLjQCC8AIlQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/workers-ai-provider/-/workers-ai-provider-3.1.4.tgz", + "integrity": "sha512-GFKh2e64nYFmzniEzl1QtjAIcn7iDK9qN+NAaGTaw5blGfCa99k2H07O/x+p55Jt/5EKiZZ40lua1Lf0uCHrRw==", "license": "MIT", "peerDependencies": { "@ai-sdk/provider": "^3.0.0", diff --git a/package.json b/package.json index d5b4e6f..3dce9f1 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "streamdown": "^2.2.0", - "workers-ai-provider": "^3.1.3", + "workers-ai-provider": "^3.1.4", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/app.tsx b/src/app.tsx index 0554b37..7496427 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -33,9 +33,38 @@ import { PlusIcon, SignInIcon, XIcon, - WrenchIcon + WrenchIcon, + PaperclipIcon, + ImageIcon } from "@phosphor-icons/react"; +// ── Attachment helpers ──────────────────────────────────────────────── + +interface Attachment { + id: string; + file: File; + preview: string; + mediaType: string; +} + +function createAttachment(file: File): Attachment { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + file, + preview: URL.createObjectURL(file), + mediaType: file.type || "application/octet-stream" + }; +} + +function fileToDataUri(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + // ── Small components ────────────────────────────────────────────────── function ThemeToggle() { @@ -194,8 +223,11 @@ function Chat() { const [connected, setConnected] = useState(false); const [input, setInput] = useState(""); const [showDebug, setShowDebug] = useState(false); + const [attachments, setAttachments] = useState([]); + const [isDragging, setIsDragging] = useState(false); const messagesEndRef = useRef(null); const textareaRef = useRef(null); + const fileInputRef = useRef(null); const toasts = useKumoToastManager(); const [mcpState, setMcpState] = useState({ prompts: [], @@ -321,18 +353,100 @@ function Chat() { } }, [isStreaming]); - const send = useCallback(() => { + const addFiles = useCallback((files: FileList | File[]) => { + const images = Array.from(files).filter((f) => f.type.startsWith("image/")); + if (images.length === 0) return; + setAttachments((prev) => [...prev, ...images.map(createAttachment)]); + }, []); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => { + const att = prev.find((a) => a.id === id); + if (att) URL.revokeObjectURL(att.preview); + return prev.filter((a) => a.id !== id); + }); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes("Files")) setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.currentTarget === e.target) setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files); + }, + [addFiles] + ); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + e.preventDefault(); + addFiles(files); + } + }, + [addFiles] + ); + + const send = useCallback(async () => { const text = input.trim(); - if (!text || isStreaming) return; + if ((!text && attachments.length === 0) || isStreaming) return; setInput(""); - sendMessage({ role: "user", parts: [{ type: "text", text }] }); - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; + + const parts: Array< + | { type: "text"; text: string } + | { type: "file"; mediaType: string; url: string } + > = []; + if (text) parts.push({ type: "text", text }); + + for (const att of attachments) { + const dataUri = await fileToDataUri(att.file); + parts.push({ type: "file", mediaType: att.mediaType, url: dataUri }); } - }, [input, isStreaming, sendMessage]); + + for (const att of attachments) URL.revokeObjectURL(att.preview); + setAttachments([]); + + sendMessage({ role: "user", parts }); + if (textareaRef.current) textareaRef.current.style.height = "auto"; + }, [input, attachments, isStreaming, sendMessage]); return ( -
+
+ {isDragging && ( +
+
+ + Drop images here +
+
+ )} + {/* Header */}
@@ -643,6 +757,28 @@ function Chat() { ); })} + {/* Image parts */} + {message.parts + .filter( + (part): part is Extract => + part.type === "file" && + (part as { mediaType?: string }).mediaType?.startsWith( + "image/" + ) === true + ) + .map((part, i) => ( +
+ Attachment +
+ ))} + {/* Text parts */} {message.parts .filter((part) => part.type === "text") @@ -691,7 +827,54 @@ function Chat() { }} className="max-w-3xl mx-auto px-5 py-4" > + { + if (e.target.files) addFiles(e.target.files); + e.target.value = ""; + }} + /> + + {attachments.length > 0 && ( +
+ {attachments.map((att) => ( +
+ {att.file.name} + +
+ ))} +
+ )} +
+