diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d28f177a179..187e5206c04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - CLI/update: keep managed Gateway service stop/restart status lines out of `openclaw update --json` stdout so package-update automation can parse the JSON payload. - Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution. - Agents/OpenAI: preserve Responses and Chat Completions `reasoning_tokens` usage metadata without double-counting it in aggregate output tokens. (#85319) +- Control UI/chat: convert pasted `data:image/...;base64,...` clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi. - Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr. - OpenAI Codex: preserve image input support for sparse `openai-codex/gpt-5.5` catalog rows. (#85095) Thanks @sercada. - Plugins/discovery: strip `-plugin` package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu. diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 7cea94781f17..0e07306d681b 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -1039,6 +1039,35 @@ describe("chat slash menu accessibility", () => { }); describe("chat attachment picker", () => { + it("converts pasted data image text into an attachment", () => { + const onAttachmentsChange = vi.fn(); + const container = renderChatView({ onAttachmentsChange }); + const textarea = requireElement( + container, + ".agent-chat__composer-combobox > textarea", + "composer textarea", + ); + const base64 = btoa("png"); + const dataUrl = ` data:image/PNG;base64,${base64.slice(0, 2)}\n${base64.slice(2)} `; + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + items: { length: 0 }, + getData: (type: string) => (type === "text/plain" ? dataUrl : ""), + }, + }); + + const allowed = textarea.dispatchEvent(event); + + expect(allowed).toBe(false); + const attachments = requireFirstAttachmentsChange(onAttachmentsChange); + expect(attachments).toHaveLength(1); + expect(attachments[0]?.fileName).toBe("pasted-image.png"); + expect(attachments[0]?.mimeType).toBe("image/png"); + expect(attachments[0]?.sizeBytes).toBe(3); + expect(getChatAttachmentDataUrl(attachments[0])).toBe(`data:image/png;base64,${base64}`); + }); + it("accepts and previews non-video file attachments", async () => { const onAttachmentsChange = vi.fn(); const container = renderChatView({ onAttachmentsChange }); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index da455355249b..c6b76124f289 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -453,6 +453,32 @@ function chatAttachmentFromFile(file: File, dataUrl: string): ChatAttachment { return registerChatAttachmentPayload({ attachment, dataUrl, file }); } +function dataImageClipboardFile(dataUrl: string): { file: File; dataUrl: string } | null { + const match = /^\s*data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)\s*$/i.exec(dataUrl); + if (!match) { + return null; + } + const mimeType = match[1].toLowerCase(); + if (!isSupportedChatAttachmentFile({ name: "pasted-image", type: mimeType })) { + return null; + } + const base64 = match[2].replace(/\s+/g, ""); + try { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; + return { + file: new File([bytes], `pasted-image.${extension}`, { type: mimeType }), + dataUrl: `data:${mimeType};base64,${base64}`, + }; + } catch { + return null; + } +} + function isImageAttachment(att: ChatAttachment): boolean { return att.mimeType.startsWith("image/"); } @@ -470,6 +496,16 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) { } } if (imageItems.length === 0) { + const text = e.clipboardData?.getData("text/plain"); + const pasted = text ? dataImageClipboardFile(text) : null; + if (!pasted) { + return; + } + e.preventDefault(); + props.onAttachmentsChange([ + ...(props.attachments ?? []), + chatAttachmentFromFile(pasted.file, pasted.dataUrl), + ]); return; } e.preventDefault();