From 065cecfbd26b1dac97c2a9964d6755848bfafc33 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 30 Sep 2026 21:15:01 -0400 Subject: [PATCH 1/2] feat(ui): image/PDF upload + media rendering in the chat UI; multimodal docs (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the forge dashboard chat to the multimodal A2A I/O already supported by the runtime (no A2A schema change β€” file parts always existed). forge-ui: - ChatRequest gains Attachments []ChatAttachment (name, mimeType, base64 data); buildChatParts projects the text + a `file` part per attachment onto the outbound A2A message. The attachment data is standard-base64 β€” exactly what the agent's FileContent.Bytes decodes from β€” so it passes straight through. The "message required" guard is relaxed to "message or attachment". - app.js: a πŸ“Ž attach button on the composer with client-side validation matching the server gate β€” accepted types (PNG/JPEG/GIF/WebP, PDF), per-file size caps (image 5 MiB, PDF 24 MiB effective), and per-message counts (20 images / 5 docs). Pending attachments show as removable chips. Received media renders in the bubble: images inline, documents as download links β€” for both the user's message and the agent's reply (MediaPart component). - style.css: media + attachment-chip styling. docs: new reference/multimodal-io.md (A2A shapes, input/output examples, capability matrix + limits, persistence, UI); web-dashboard.md chat rows; README index link. Tests: buildChatParts projection (text/image/attachment-only) + the relaxed message-or-attachment guard. --- README.md | 1 + docs/reference/multimodal-io.md | 108 +++++++++++++++++++ docs/reference/web-dashboard.md | 2 + forge-ui/chat.go | 35 +++++-- forge-ui/chat_media_test.go | 83 +++++++++++++++ forge-ui/static/app.js | 180 +++++++++++++++++++++++++++++--- forge-ui/static/style.css | 74 +++++++++++++ forge-ui/types.go | 15 ++- 8 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 docs/reference/multimodal-io.md create mode 100644 forge-ui/chat_media_test.go diff --git a/README.md b/README.md index 8a155210..3071c023 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ You write a `SKILL.md`. Forge compiles it into a secure, runnable agent with egr | [Configuration](docs/reference/forge-yaml-schema.md) | `forge.yaml` schema and environment variables | | [Settings](docs/reference/settings.md) | Layered developer settings (user + managed/MDM) β€” enabled channels, model default + gateway, builtin tools | | [Dashboard](docs/reference/web-dashboard.md) | Web UI features and architecture | +| [Multimodal I/O](docs/reference/multimodal-io.md) | Sending images/PDFs to an agent and receiving media back over A2A | | [Deployment](docs/deployment/kubernetes.md) | Container packaging, Kubernetes, air-gap | | [Scheduler β€” Kubernetes](docs/deployment/scheduler-kubernetes.md) | Hybrid file/CronJob scheduler backend, RBAC, token plumbing | | [Hooks](docs/core-concepts/hooks.md) | Agent loop hook system | diff --git a/docs/reference/multimodal-io.md b/docs/reference/multimodal-io.md new file mode 100644 index 00000000..41402e64 --- /dev/null +++ b/docs/reference/multimodal-io.md @@ -0,0 +1,108 @@ +--- +title: "Multimodal I/O over A2A" +description: "Sending images and PDFs to a forge agent, and receiving media back, over the A2A protocol." +order: 9 +--- + +# Multimodal I/O over A2A + +Forge agents accept **images** and **PDF documents** as input and can return media as output β€” over the **existing A2A message schema**. No envelope change was required: A2A already modeled media as a `file` part; forge now honors those parts instead of dropping them (#255). + +> **Backward compatible.** Text-only messages are unchanged and serialize byte-for-byte as before. Media support is purely additive. + +## The A2A shape (unchanged) + +A message is a list of typed parts. The media type is `file`: + +```jsonc +// a2a.Part +{ "kind": "file", "file": { "name": "photo.png", "mimeType": "image/png", "bytes": "" } } +``` + +`FileContent` fields: `name` (optional), `mimeType` (required for media), `bytes` (the raw file **standard-base64-encoded**), or `uri` (by reference). + +> **Inline `bytes` only, today.** Forge feeds media to the model from inline `bytes`. A `file` part with only a `uri` (no bytes) is **not fetched** β€” it's rejected. URI-fetch (with egress control) is a tracked follow-up. Send `bytes`. + +## Input β€” send an image or PDF + +### JSON-RPC (`POST /`) + +```json +{ + "jsonrpc": "2.0", "id": 1, "method": "tasks/send", + "params": { + "id": "t-1", + "message": { + "role": "user", + "parts": [ + { "kind": "text", "text": "What's in this image?" }, + { "kind": "file", "file": { "name": "photo.png", "mimeType": "image/png", "bytes": "iVBORw0KGgo..." } } + ] + } + } +} +``` + +### REST (`POST /tasks/send`) + +Same `message`, wrapped in the REST envelope: + +```json +{ "task": { "id": "t-1", "message": { "role": "user", "parts": [ /* …same parts… */ ] } } } +``` + +A PDF is identical with `"mimeType": "application/pdf"`. + +### What the model must support + +Media the resolved model can't consume is **rejected loudly** (a 4xx / JSON-RPC error naming the part + reason) β€” never silently dropped and answered anyway. + +| Media | MIME | Capable models | +|-------|------|----------------| +| Image | `image/png`, `image/jpeg`, `image/gif`, `image/webp` | vision models β€” OpenAI `gpt-4o`/`gpt-4.1`/`gpt-5`/`o1`/`o3`/`o4`, Anthropic Claude 3+, Gemini 1.5/2 | +| PDF | `application/pdf` | Anthropic Sonnet 3.5+, Opus 4+, Haiku 4.5+, Fable 5 | + +### Limits (enforced at ingest) + +| Bound | Limit | Reject reason | +|-------|-------|---------------| +| Per-image bytes | 5 MiB | `image_limit_exceeded` | +| Image dimensions | 100 000 px/side, 50 MP total | `image_limit_exceeded` | +| Images per message | 20 | `too_many_image_parts` | +| Per-PDF bytes | 32 MiB (+`%PDF-` sniff) | `document_limit_exceeded` | +| PDFs per message | 5 | `too_many_document_parts` | +| Request body | 32 MiB (both transports) | HTTP 413 | +| Concurrent media requests | 4 | 429 / unavailable (shed) | + +A rejection emits an [`input_media_rejected`](../security/audit-logging.md) audit event. Note media **bytes aren't text-scannable**, so guardrail/intent scanning applies to the text/data parts only. + +## Output β€” receive media back + +Also the existing A2A shape: the response `message.parts` can carry `file` parts alongside text. + +```json +{ + "role": "agent", + "parts": [ + { "kind": "text", "text": "Here's the chart:" }, + { "kind": "file", "file": { "mimeType": "image/png", "bytes": "iVBORw0KGgo..." } } + ] +} +``` + +File parts come from two sources: + +- **Tools** that produce files (`file_create`, `browser_screenshot`) β€” always on. +- **Model-generated images** β€” opt-in via `models.default.image_generation: true` on an `openai-responses` model (sends the `image_generation` built-in tool; see [forge.yaml schema](forge-yaml-schema.md)). + +## Persistence + +Inbound uploads and model-generated output are written under the agent's files dir β€” `.forge/files/inbound/` and `.forge/files/generated/` respectively β€” content-addressed. Session history stores only the on-disk path (never base64), and media is reloaded per turn, so multi-turn conversations keep their media without bloating the session file. See [Runtime Engine β†’ Image and document input](../core-concepts/runtime-engine.md#image-and-document-input-multimodal). + +## In the web dashboard + +The [chat UI](web-dashboard.md) has a **πŸ“Ž attach** button: select one or more images/PDFs (validated against the accepted types and size caps above), send them with your message, and see images rendered inline and documents as download links β€” in both your message and the agent's reply. + +## Follow-ups (not yet supported) + +URI-fetch input (egress-gated) Β· OpenAI Responses `input_file` documents Β· Gemini/Bedrock media Β· text-extraction fallback for non-native document models Β· remote/distributed session media replay Β· streaming (partial-image) generated output. diff --git a/docs/reference/web-dashboard.md b/docs/reference/web-dashboard.md index 94a86ef9..ff0238db 100644 --- a/docs/reference/web-dashboard.md +++ b/docs/reference/web-dashboard.md @@ -55,6 +55,8 @@ Click any running agent to open a chat interface that streams responses via the | Markdown rendering | Code blocks, tables, lists rendered inline | | Session history | Browse and resume previous conversations | | Tool call visibility | See which tools the agent invokes during execution | +| Attachments (πŸ“Ž) | Attach images (PNG/JPEG/GIF/WebP) and PDFs to a message β€” validated against the accepted types and size caps before upload. Requires a model that supports the modality. See [Multimodal I/O](multimodal-io.md). | +| Media replies | Images the agent returns render inline; documents appear as download links. | ## Create Agent Wizard diff --git a/forge-ui/chat.go b/forge-ui/chat.go index f27751d5..fa71d63c 100644 --- a/forge-ui/chat.go +++ b/forge-ui/chat.go @@ -30,8 +30,8 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid request body") return } - if req.Message == "" { - writeError(w, http.StatusBadRequest, "message is required") + if req.Message == "" && len(req.Attachments) == 0 { + writeError(w, http.StatusBadRequest, "message or attachment is required") return } @@ -53,6 +53,8 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) { sessionID = fmt.Sprintf("%s-%d", agentID, time.Now().UnixNano()) } + parts := buildChatParts(req.Message, req.Attachments) + // Build A2A JSON-RPC request for tasks/sendSubscribe. rpcBody, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", @@ -61,10 +63,8 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) { "params": map[string]any{ "id": sessionID, "message": map[string]any{ - "role": "user", - "parts": []map[string]any{ - {"kind": "text", "text": req.Message}, - }, + "role": "user", + "parts": parts, }, }, }) @@ -152,6 +152,29 @@ func (s *UIServer) handleChat(w http.ResponseWriter, r *http.Request) { flusher.Flush() } +// buildChatParts projects the chat message text plus any attachments into A2A +// message parts: the text (if non-empty) as a `text` part, then a `file` part +// per attachment. Each attachment's Data is already standard-base64 β€” exactly +// what the agent's FileContent.Bytes ([]byte) decodes from β€” so it passes +// straight through onto the wire (#255). +func buildChatParts(message string, attachments []ChatAttachment) []map[string]any { + parts := make([]map[string]any, 0, 1+len(attachments)) + if message != "" { + parts = append(parts, map[string]any{"kind": "text", "text": message}) + } + for _, att := range attachments { + parts = append(parts, map[string]any{ + "kind": "file", + "file": map[string]any{ + "name": att.Name, + "mimeType": att.MimeType, + "bytes": att.Data, + }, + }) + } + return parts +} + // handleListSessions returns stored chat sessions for an agent. func (s *UIServer) handleListSessions(w http.ResponseWriter, r *http.Request) { agentID := r.PathValue("id") diff --git a/forge-ui/chat_media_test.go b/forge-ui/chat_media_test.go new file mode 100644 index 00000000..722bafe0 --- /dev/null +++ b/forge-ui/chat_media_test.go @@ -0,0 +1,83 @@ +package forgeui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestBuildChatParts verifies the chat β†’ A2A part projection: text becomes a +// text part, each attachment a file part with its base64 bytes passed through +// unchanged (#255). +func TestBuildChatParts(t *testing.T) { + t.Run("text only", func(t *testing.T) { + parts := buildChatParts("hello", nil) + if len(parts) != 1 || parts[0]["kind"] != "text" || parts[0]["text"] != "hello" { + t.Fatalf("parts = %+v, want one text part", parts) + } + }) + + t.Run("text + image attachment", func(t *testing.T) { + parts := buildChatParts("look", []ChatAttachment{ + {Name: "p.png", MimeType: "image/png", Data: "aW1nLWJhc2U2NA=="}, + }) + if len(parts) != 2 { + t.Fatalf("parts = %+v, want [text, file]", parts) + } + if parts[1]["kind"] != "file" { + t.Fatalf("second part kind = %v, want file", parts[1]["kind"]) + } + file, ok := parts[1]["file"].(map[string]any) + if !ok { + t.Fatalf("file payload malformed: %+v", parts[1]["file"]) + } + if file["mimeType"] != "image/png" || file["name"] != "p.png" { + t.Errorf("file meta = %+v", file) + } + if file["bytes"] != "aW1nLWJhc2U2NA==" { + t.Errorf("base64 bytes must pass through unchanged; got %v", file["bytes"]) + } + }) + + t.Run("attachment only (no text) omits the text part", func(t *testing.T) { + parts := buildChatParts("", []ChatAttachment{{MimeType: "application/pdf", Data: "JVBERi0="}}) + if len(parts) != 1 || parts[0]["kind"] != "file" { + t.Fatalf("parts = %+v, want a single file part", parts) + } + }) +} + +// TestHandleChat_RequiresMessageOrAttachment: the relaxed guard β€” a message +// with neither text nor attachments is a 400; an attachment-only message passes +// validation (and then fails later only because no agent is running). +func TestHandleChat_RequiresMessageOrAttachment(t *testing.T) { + s, _ := newTestServer(t) + + t.Run("empty message and no attachments β†’ 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/chat", strings.NewReader(`{"message":""}`)) + req.SetPathValue("id", "test-agent") + rec := httptest.NewRecorder() + s.handleChat(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "message or attachment") { + t.Errorf("body = %s", rec.Body.String()) + } + }) + + t.Run("attachment-only passes validation (fails later on no running agent)", func(t *testing.T) { + body := `{"message":"","attachments":[{"mimeType":"image/png","data":"eA=="}]}` + req := httptest.NewRequest(http.MethodPost, "/api/agents/test-agent/chat", strings.NewReader(body)) + req.SetPathValue("id", "test-agent") + rec := httptest.NewRecorder() + s.handleChat(rec, req) + // Past the message/attachment guard; the next gate (agent not running) + // fires instead β€” i.e. the attachment-only request was NOT rejected for + // lacking a message. + if strings.Contains(rec.Body.String(), "message or attachment") { + t.Errorf("attachment-only must pass the message guard; got %s", rec.Body.String()) + } + }) +} diff --git a/forge-ui/static/app.js b/forge-ui/static/app.js index e919742d..d49508d9 100644 --- a/forge-ui/static/app.js +++ b/forge-ui/static/app.js @@ -692,11 +692,11 @@ function useChatStream(agentId) { setSessionId(null); }, []); - const sendMessage = useCallback(async (text) => { + const sendMessage = useCallback(async (text, attachments = []) => { if (streaming) return; - // Append user message - setMessages(prev => [...prev, { role: 'user', content: text }]); + // Append user message (with any attachments for display) + setMessages(prev => [...prev, { role: 'user', content: text, attachments }]); setStreaming(true); const controller = new AbortController(); @@ -709,6 +709,7 @@ function useChatStream(agentId) { body: JSON.stringify({ message: text, session_id: sessionId || undefined, + attachments: attachments.map(a => ({ name: a.name, mimeType: a.mimeType, data: a.data })), }), signal: controller.signal, }); @@ -726,8 +727,17 @@ function useChatStream(agentId) { let buffer = ''; let currentTools = []; let agentText = ''; + let agentFiles = []; // file parts (images/docs the agent returned) β€” #255 let receivedSessionId = sessionId; + // Collect `file` parts from a message, newest frame wins (frames carry + // the complete message, so replace rather than append to avoid dupes). + const collectFiles = (msg) => { + if (!msg || !msg.parts) return; + const files = msg.parts.filter(p => p.kind === 'file' && p.file).map(p => p.file); + if (files.length) agentFiles = files; + }; + while (true) { const { done, value } = await reader.read(); if (done) break; @@ -764,16 +774,17 @@ function useChatStream(agentId) { agentText = part.text; } } + collectFiles(status.message); } // Update messages in real-time to show text as it arrives setMessages(prev => { const last = prev[prev.length - 1]; if (last && last.role === 'agent' && last.isStreaming) { const updated = [...prev]; - updated[updated.length - 1] = { ...last, content: agentText, tools: [...currentTools] }; + updated[updated.length - 1] = { ...last, content: agentText, tools: [...currentTools], files: [...agentFiles] }; return updated; } - return [...prev, { role: 'agent', content: agentText, tools: [...currentTools], isStreaming: true }]; + return [...prev, { role: 'agent', content: agentText, tools: [...currentTools], files: [...agentFiles], isStreaming: true }]; }); } else if (eventType === 'progress') { // Tool execution progress. The agent carries the tool name and @@ -822,16 +833,17 @@ function useChatStream(agentId) { agentText = part.text; } } + collectFiles(status.message); } // Show final text immediately setMessages(prev => { const last = prev[prev.length - 1]; if (last && last.role === 'agent' && last.isStreaming) { const updated = [...prev]; - updated[updated.length - 1] = { ...last, content: agentText, tools: [...currentTools] }; + updated[updated.length - 1] = { ...last, content: agentText, tools: [...currentTools], files: [...agentFiles] }; return updated; } - return [...prev, { role: 'agent', content: agentText, tools: [...currentTools], isStreaming: true }]; + return [...prev, { role: 'agent', content: agentText, tools: [...currentTools], files: [...agentFiles], isStreaming: true }]; }); } else if (eventType === 'done') { if (parsed.session_id) { @@ -843,11 +855,11 @@ function useChatStream(agentId) { } // Finalize: set the agent response - if (agentText || currentTools.length > 0) { + if (agentText || currentTools.length > 0 || agentFiles.length > 0) { setMessages(prev => { // Remove streaming placeholder if present const filtered = prev.filter(m => !m.isStreaming); - return [...filtered, { role: 'agent', content: agentText, tools: currentTools }]; + return [...filtered, { role: 'agent', content: agentText, tools: currentTools, files: agentFiles }]; }); } @@ -1176,6 +1188,66 @@ function ToolCard({ tool }) { // ── Message Bubble Component ───────────────────────────────── +// Accepted upload types + size caps β€” kept in sync with the server-side gate +// (forge-core/runtime: IsImageMIME/IsDocumentMIME, MaxImagePartBytes, +// MaxDocumentPartBytes, MaxImagePartsPerMessage, MaxDocumentPartsPerMessage). +// Enforced client-side so the user gets instant feedback instead of a 4xx. +const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; +const ACCEPTED_DOC_TYPES = ['application/pdf']; +const ACCEPTED_MEDIA_TYPES = [...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_DOC_TYPES]; +const ACCEPT_ATTR = ACCEPTED_MEDIA_TYPES.join(','); +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MiB per image (MaxImagePartBytes) +// PDFs cap at 32 MiB server-side, but base64 inflation vs the 32 MiB body cap +// makes ~24 MiB raw the effective ceiling β€” enforce that here. +const MAX_PDF_BYTES = 24 * 1024 * 1024; +const MAX_IMAGES_PER_MSG = 20; +const MAX_DOCS_PER_MSG = 5; + +// readFileAsAttachment reads a File into { name, mimeType, data(base64), size }, +// or rejects with a human-readable reason if the type/size isn't allowed. +function readFileAsAttachment(file) { + return new Promise((resolve, reject) => { + const mt = (file.type || '').toLowerCase(); + if (!ACCEPTED_MEDIA_TYPES.includes(mt)) { + reject(new Error(`${file.name}: unsupported type (${file.type || 'unknown'}). Allowed: PNG, JPEG, GIF, WebP, PDF.`)); + return; + } + const isImage = ACCEPTED_IMAGE_TYPES.includes(mt); + const cap = isImage ? MAX_IMAGE_BYTES : MAX_PDF_BYTES; + if (file.size > cap) { + reject(new Error(`${file.name}: too large (${(file.size / 1048576).toFixed(1)} MiB, max ${(cap / 1048576).toFixed(0)} MiB).`)); + return; + } + const reader = new FileReader(); + reader.onerror = () => reject(new Error(`${file.name}: could not be read.`)); + reader.onload = () => { + // readAsDataURL β†’ "data:;base64,"; strip the prefix. + const b64 = String(reader.result).split(',')[1] || ''; + resolve({ name: file.name, mimeType: mt, data: b64, size: file.size, isImage }); + }; + reader.readAsDataURL(file); + }); +} + +// MediaPart renders one image/document as an inline (images) or a +// download chip (everything else), from base64 bytes. Used for both the user's +// attachments and media the agent returns (#255). b64 is standard base64. +function MediaPart({ mimeType, name, b64 }) { + const mt = (mimeType || '').toLowerCase(); + if (!b64) { + return html`
\u{1F4CE}${name || mt || 'file'}
`; + } + const src = `data:${mt || 'application/octet-stream'};base64,${b64}`; + if (mt.startsWith('image/')) { + return html` + ${name + `; + } + return html` + \u{1F4C4}${name || mt} + `; +} + function MessageBubble({ message }) { if (message.role === 'error') { return html` @@ -1188,7 +1260,12 @@ function MessageBubble({ message }) { if (message.role === 'user') { return html`
-
${message.content}
+ ${message.attachments && message.attachments.length > 0 && html` +
+ ${message.attachments.map((a, i) => html`<${MediaPart} key=${i} mimeType=${a.mimeType} name=${a.name} b64=${a.data} />`)} +
+ `} + ${message.content && html`
${message.content}
`}
`; } @@ -1204,7 +1281,12 @@ function MessageBubble({ message }) { ${message.content && html`
`} - ${message.isStreaming && !message.content && html` + ${message.files && message.files.length > 0 && html` +
+ ${message.files.map((f, i) => html`<${MediaPart} key=${i} mimeType=${f.mimeType} name=${f.name} b64=${f.bytes} />`)} +
+ `} + ${message.isStreaming && !message.content && (!message.files || message.files.length === 0) && html`
`}
@@ -1220,10 +1302,13 @@ function ChatPage({ agentId, agents }) { const { messages, streaming, sessionId, sendMessage, loadSession, newSession, cancel } = useChatStream(agentId); const [sessions, setSessions] = useState([]); const [inputText, setInputText] = useState(''); + const [attachments, setAttachments] = useState([]); + const [attachError, setAttachError] = useState(''); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const userScrolledUp = useRef(false); const textareaRef = useRef(null); + const fileInputRef = useRef(null); // Load sessions on mount useEffect(() => { @@ -1245,13 +1330,50 @@ function ChatPage({ agentId, agents }) { const handleSend = useCallback(() => { const text = inputText.trim(); - if (!text || streaming || !isRunning) return; + if ((!text && attachments.length === 0) || streaming || !isRunning) return; setInputText(''); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } - sendMessage(text); - }, [inputText, streaming, isRunning, sendMessage]); + sendMessage(text, attachments); + setAttachments([]); + setAttachError(''); + }, [inputText, attachments, streaming, isRunning, sendMessage]); + + const handleFiles = useCallback(async (fileList) => { + const files = Array.from(fileList || []); + if (files.length === 0) return; + setAttachError(''); + const accepted = []; + const errors = []; + for (const f of files) { + try { + accepted.push(await readFileAsAttachment(f)); + } catch (err) { + errors.push(err.message); + } + } + setAttachments(prev => { + const next = [...prev, ...accepted]; + // Enforce per-message counts (images vs docs) β€” keep earliest, drop overflow. + const imgs = next.filter(a => a.isImage); + const docs = next.filter(a => !a.isImage); + if (imgs.length > MAX_IMAGES_PER_MSG) errors.push(`At most ${MAX_IMAGES_PER_MSG} images per message.`); + if (docs.length > MAX_DOCS_PER_MSG) errors.push(`At most ${MAX_DOCS_PER_MSG} documents per message.`); + const capped = [...imgs.slice(0, MAX_IMAGES_PER_MSG), ...docs.slice(0, MAX_DOCS_PER_MSG)]; + if (errors.length) setAttachError(errors.join(' ')); + return capped; + }); + }, []); + + const onFileInputChange = useCallback((e) => { + handleFiles(e.target.files); + e.target.value = ''; // allow re-selecting the same file + }, [handleFiles]); + + const removeAttachment = useCallback((idx) => { + setAttachments(prev => prev.filter((_, i) => i !== idx)); + }, []); const handleKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -1318,6 +1440,19 @@ function ChatPage({ agentId, agents }) {
+ ${attachments.length > 0 && html` +
+ ${attachments.map((a, i) => html` +
+ ${a.isImage ? '\u{1F5BC}️' : '\u{1F4C4}'} + ${a.name} + ${(a.size / 1024).toFixed(0)} KB + +
+ `)} +
+ `} + ${attachError && html`
${attachError}
`}