From 53e93a193220b0ce578b094d1dc9c433c734ef83 Mon Sep 17 00:00:00 2001 From: RogerHsu7 Date: Fri, 30 Jan 2026 10:36:51 +0800 Subject: [PATCH 1/5] Create webchat-file-upload.md --- docs/proposals/webchat-file-upload.md | 89 +++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/proposals/webchat-file-upload.md diff --git a/docs/proposals/webchat-file-upload.md b/docs/proposals/webchat-file-upload.md new file mode 100644 index 000000000..4e53ebe46 --- /dev/null +++ b/docs/proposals/webchat-file-upload.md @@ -0,0 +1,89 @@ +# Gateway Web Chat: Image/File Upload + +Status: draft +Owner: @assistant +Scope: Gateway web console (Chat) + +## Problem +The Gateway web Chat has no way to send images/files. Users often need to share screenshots/logs during troubleshooting. + +## Goals +- Allow users to attach files via: attach button, drag & drop, paste-from-clipboard (images). +- Show selected files as removable chips with upload progress. +- On send, upload files to server and insert a message with media references so assistants receive MEDIA:/path. +- Support multiple files per message. +- Reasonable defaults for limits (20–50MB per item) and allow config override. + +## Non-goals +- Inline buttons/interactive UI in messages (not supported on webchat). +- Server-side virus scanning. + +## UX +- Add "Attach" icon next to the composer. +- Dragging files over the composer shows a drop overlay. +- Pasting an image inserts it as a pending attachment. +- Each selected file shows: name/size, thumbnail for images, remove (×), progress bar while uploading. +- Pressing Enter (without Shift) sends text + queued attachments. + +## API +- POST /api/media/upload (multipart/form-data) + - fields: file (repeatable), caption (optional, per-file or per-message caption TBD) + - returns 200 JSON: { files: [ { path: "MEDIA:/path", mime: "image/png", name: "...", size: 12345, width, height } ] } + - saves files under gateway media path (e.g., ~/.clawdbot/media/inbound/.) +- POST /api/sessions/:sessionId/messages + - body: { text?: string, media?: [ { path: "MEDIA:/...", caption?: string } ] } + - server stores transcript and delivers to assistant runtime. + +Notes: +- If a single endpoint already exists to send messages, extend it to accept media[]. Otherwise, use two-phase (upload -> send). +- Respect existing auth/session; CSRF as per current gateway conventions. + +## Backend +- Add multipart handling (e.g., busboy/multer/fastify-multipart depending on stack). +- Validate content type and size; enforce configurable limits. +- Compute a safe filename; store to media dir; produce absolute server path and MEDIA:/ prefix for assistant. +- For images: attempt to read dimensions (optional) to improve thumbnails. +- Return JSON list of stored files. +- Extend message creation to accept media[]. Persist media metadata alongside messages for rendering. + +## Frontend +- Composer component changes: + - Add hidden bound to Attach button. + - Drag & drop overlay and handlers (dragenter/dragover/drop) on composer area. + - Clipboard paste handler: capture image blobs and add to attachment queue. + - Attachment queue state: { id, file|blob, name, size, previewURL, status: queued|uploading|done|error, serverPath? }[] + - On send: for any queued files not uploaded, call /api/media/upload, show per-file progress. Then call send-message with text + media paths. + - After success: clear input and queue. +- Message renderer: if message.media exists, render thumbnails for images and file tiles for others; clicking downloads the file. + +## Config +- gateway.webchat.uploads.maxItemMB (default 25) +- gateway.webchat.uploads.maxFilesPerMessage (default 5) +- gateway.webchat.uploads.accept (default images+generic: "image/*,application/pdf,.zip,.txt") + +## Security +- Store under per-instance media directory; do not serve arbitrary FS paths. +- Serve media via controlled route with auth (or signed URLs) to avoid leaking private files. +- Sanitize filenames; block executables by default if desired. + +## Acceptance Criteria +- Drag a screenshot into composer -> shows chip -> send -> message appears with thumbnail; assistant receives MEDIA:/path. +- Click attach to select multiple files -> progress -> all appear in transcript. +- Paste an image from clipboard -> becomes attachment and can be sent. +- Oversize file is rejected with a clear error. + +## Test Plan +- Unit: multipart handler, limit enforcement, message payload schema. +- E2E: browser test for drag/drop, paste, attach; verify message persistence and media retrieval. + +## Open Questions +- Do we want per-file captions? For MVP we can skip. +- Serving media: public path vs. signed URL behind auth — follow current Gateway approach. + +## Task Breakdown +- [ ] Backend: media upload endpoint + storage +- [ ] Backend: extend message API to accept media[] +- [ ] Frontend: composer (attach/drag/paste) + queue + progress +- [ ] Frontend: message renderer for media +- [ ] Config + docs +- [ ] Tests From 01e2e9264c5c715f4a31d3550822dac2ed430405 Mon Sep 17 00:00:00 2001 From: RogerHsu7 Date: Fri, 30 Jan 2026 10:38:33 +0800 Subject: [PATCH 2/5] Create PR_BODY_webchat_upload.md --- docs/pr/PR_BODY_webchat_upload.md | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/pr/PR_BODY_webchat_upload.md diff --git a/docs/pr/PR_BODY_webchat_upload.md b/docs/pr/PR_BODY_webchat_upload.md new file mode 100644 index 000000000..c8105f247 --- /dev/null +++ b/docs/pr/PR_BODY_webchat_upload.md @@ -0,0 +1,32 @@ +Title: Gateway Web Chat: add image/file upload (attach, drag-drop, paste) + +Summary +- Adds attachment support in Gateway web Chat: attach button, drag & drop, and paste-from-clipboard for images. +- Backend: multipart upload endpoint that stores files under the instance media dir and returns MEDIA:/ paths; message API extended to accept media[]. +- Frontend: composer queue with file chips, upload progress; message renderer for thumbnails and file tiles. + +Motivation +Users of the web console need to share screenshots/logs directly in the browser. Today there is no upload mechanism. + +Implementation (draft) +- Backend + - POST /api/media/upload (multipart, repeatable file field). Saves to ~/.clawdbot/media/inbound/., returns { files: [{ path: 'MEDIA:/...', mime, name, size }] }. + - Extend message creation to accept media[] in addition to text. +- Frontend + - Attach button + hidden input[type=file] multiple. + - Drag & drop overlay and handlers on composer. + - Paste handler captures image blobs from clipboard. + - Attachment queue with progress; after upload, send text + media paths. + - Message renderer displays thumbnails for images and links for other files. + +Config & Limits +- Defaults: 25MB per file; up to 5 files per message. Configurable via gateway config. + +Security +- Sanitized filenames; type/size validation; stored under instance media path; served via existing media route (auth/signed URLs as applicable). + +Acceptance Criteria +- Drag/paste/attach works; messages show thumbnails or file tiles; assistant receives MEDIA:/path. + +Notes +This PR ships as a draft to align on API shape and UI placement. Happy to adjust to the codebase conventions and routing. From 76ec00aa3bb4061792f0cc5e4d3a1f64e707f438 Mon Sep 17 00:00:00 2001 From: RogerHsu7 Date: Fri, 30 Jan 2026 10:39:44 +0800 Subject: [PATCH 3/5] Create mediaUpload.ts --- drafts/webchat-upload/backend/mediaUpload.ts | 69 ++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 drafts/webchat-upload/backend/mediaUpload.ts diff --git a/drafts/webchat-upload/backend/mediaUpload.ts b/drafts/webchat-upload/backend/mediaUpload.ts new file mode 100644 index 000000000..713acfe3b --- /dev/null +++ b/drafts/webchat-upload/backend/mediaUpload.ts @@ -0,0 +1,69 @@ +// Draft implementation for Gateway media upload (Fastify-style) +// Adjust to the actual server framework (Fastify/Express) used by clawdbot gateway. + +import path from 'node:path' +import fs from 'node:fs/promises' +import { randomUUID } from 'node:crypto' + +// Pseudo helpers – replace with gateway's real config/log/auth utilities +const CONFIG = { + uploads: { + maxItemBytes: 25 * 1024 * 1024, + accept: [ 'image/', 'application/pdf', 'text/plain', 'application/zip' ], + }, + mediaDir: path.resolve(process.env.CLAWDBOT_MEDIA_DIR || path.join(process.env.HOME || process.cwd(), '.clawdbot', 'media', 'inbound')), +} + +function ensureDir(p: string) { + return fs.mkdir(p, { recursive: true }) +} + +function inferExt(mime: string, name?: string) { + const fromName = name && path.extname(name) + if (fromName) return fromName + if (mime.startsWith('image/')) return '.png' + if (mime === 'application/pdf') return '.pdf' + return '' +} + +function allowed(mime: string) { + return CONFIG.uploads.accept.some(a => a.endsWith('/*') ? mime.startsWith(a.slice(0, -1)) : mime === a) +} + +export async function registerMediaUploadRoute(fastify: any) { + await ensureDir(CONFIG.mediaDir) + + fastify.register(import('@fastify/multipart'), { limits: { fileSize: CONFIG.uploads.maxItemBytes } }) + + fastify.post('/api/media/upload', async (req: any, reply: any) => { + const mp = await req.parts() + const files: any[] = [] + + for await (const part of mp) { + if (part.type !== 'file') continue + const { filename, mimetype } = part + if (!allowed(mimetype)) { + await part.file?.resume() + return reply.code(415).send({ error: 'unsupported_type', mimetype }) + } + const id = randomUUID() + const ext = inferExt(mimetype, filename) + const outPath = path.join(CONFIG.mediaDir, `${id}${ext}`) + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of part.file) { + size += chunk.length + if (size > CONFIG.uploads.maxItemBytes) { + return reply.code(413).send({ error: 'file_too_large' }) + } + chunks.push(chunk) + } + const buf = Buffer.concat(chunks) + await fs.writeFile(outPath, buf) + const mediaPath = `MEDIA:${outPath}` + files.push({ path: mediaPath, mime: mimetype, name: filename, size }) + } + + return reply.send({ files }) + }) +} From c259dfebefa486abbbc224c908a78bdb4a78c62d Mon Sep 17 00:00:00 2001 From: RogerHsu7 Date: Fri, 30 Jan 2026 10:40:56 +0800 Subject: [PATCH 4/5] Create ChatComposerAttachment.tsx --- .../frontend/ChatComposerAttachment.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 drafts/webchat-upload/frontend/ChatComposerAttachment.tsx diff --git a/drafts/webchat-upload/frontend/ChatComposerAttachment.tsx b/drafts/webchat-upload/frontend/ChatComposerAttachment.tsx new file mode 100644 index 000000000..2b49d00a7 --- /dev/null +++ b/drafts/webchat-upload/frontend/ChatComposerAttachment.tsx @@ -0,0 +1,100 @@ +// Draft React snippet: add attach button + drag & drop + paste image support +// Integrate into the existing web console front-end (adjust imports/styles/state mgmt). + +import React, { useCallback, useEffect, useRef, useState } from 'react' + +type QueueItem = { + id: string + file: File + previewURL?: string + status: 'queued' | 'uploading' | 'done' | 'error' + serverPath?: string + error?: string +} + +async function uploadFiles(items: QueueItem[]): Promise { + const form = new FormData() + items.forEach(i => form.append('file', i.file, i.file.name)) + const res = await fetch('/api/media/upload', { method: 'POST', body: form }) + if (!res.ok) throw new Error(`upload failed: ${res.status}`) + const json = await res.json() + return items.map((it, idx) => ({ ...it, status: 'done', serverPath: json.files[idx]?.path })) +} + +export function ChatComposerAttachment({ onSend }: { onSend: (payload: { text?: string, media?: { path: string }[] }) => Promise }) { + const [text, setText] = useState('') + const [queue, setQueue] = useState([]) + const inputRef = useRef(null) + + const pickFiles = () => inputRef.current?.click() + const onFileChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []) + const next = files.map(f => ({ id: crypto.randomUUID(), file: f, previewURL: f.type.startsWith('image/') ? URL.createObjectURL(f) : undefined, status: 'queued' as const })) + setQueue(q => [...q, ...next]) + } + + const onDrop = (e: React.DragEvent) => { + e.preventDefault() + const files = Array.from(e.dataTransfer.files || []) + const next = files.map(f => ({ id: crypto.randomUUID(), file: f, previewURL: f.type.startsWith('image/') ? URL.createObjectURL(f) : undefined, status: 'queued' as const })) + setQueue(q => [...q, ...next]) + } + + const onPaste = useCallback((e: ClipboardEvent) => { + const items = Array.from(e.clipboardData?.items || []) + const files = items.filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean) as File[] + if (files.length) { + e.preventDefault() + const next = files.map(f => ({ id: crypto.randomUUID(), file: f, previewURL: f.type.startsWith('image/') ? URL.createObjectURL(f) : undefined, status: 'queued' as const })) + setQueue(q => [...q, ...next]) + } + }, []) + + useEffect(() => { + const handler = (ev: ClipboardEvent) => onPaste(ev) + window.addEventListener('paste', handler) + return () => window.removeEventListener('paste', handler) + }, [onPaste]) + + const send = async () => { + // upload queued files + const queued = queue.filter(q => q.status === 'queued') + let uploaded: QueueItem[] = [] + if (queued.length) { + setQueue(q => q.map(i => i.status === 'queued' ? { ...i, status: 'uploading' } : i)) + try { + uploaded = await uploadFiles(queued) + setQueue(q => q.map(i => i.status !== 'queued' ? i : uploaded.find(u => u.file === i.file) || i)) + } catch (e: any) { + setQueue(q => q.map(i => i.status === 'uploading' ? { ...i, status: 'error', error: String(e?.message || e) } : i)) + return + } + } + + const media = queue.map(i => i.serverPath).filter(Boolean).map(p => ({ path: p! })) + await onSend({ text: text.trim() || undefined, media: media.length ? media : undefined }) + setText('') + setQueue([]) + } + + return ( +
e.preventDefault()} onDrop={onDrop}> + + + setText(e.target.value)} placeholder="Message (paste images or drag files here)" onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} /> + + {queue.length > 0 && ( +
+ {queue.map(it => ( +
+ {it.previewURL ? {it.file.name} : } + {it.file.name} + {(it.file.size/1024).toFixed(1)} KB + {it.status === 'uploading' && } +
+ ))} +
+ )} +
+ ) +} From f0a94d07d8b2ae53853f3e45120704b6b1a55a9c Mon Sep 17 00:00:00 2001 From: RogerHsu7 Date: Fri, 30 Jan 2026 10:41:33 +0800 Subject: [PATCH 5/5] Create MessageMedia.tsx --- .../webchat-upload/frontend/MessageMedia.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 drafts/webchat-upload/frontend/MessageMedia.tsx diff --git a/drafts/webchat-upload/frontend/MessageMedia.tsx b/drafts/webchat-upload/frontend/MessageMedia.tsx new file mode 100644 index 000000000..858cc6266 --- /dev/null +++ b/drafts/webchat-upload/frontend/MessageMedia.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +type Media = { path: string, mime?: string, name?: string } + +function isImage(m?: string, path?: string) { + return (m && m.startsWith('image/')) || (path && path.match(/\.(png|jpe?g|webp|gif)$/i)) +} + +export function MessageMedia({ media }: { media: Media[] }) { + if (!media?.length) return null + return ( +
+ {media.map((m, i) => ( +
+ {isImage(m.mime, m.path) ? ( + // MEDIA:/absolute/path should be proxied/served by the gateway; adjust URL transformation accordingly + {m.name + ) : ( + {m.name || 'file'} + )} +
+ ))} +
+ ) +}