From bad30d5a7419623500ac84fc1741db04b6b8b754 Mon Sep 17 00:00:00 2001 From: Ittiz Date: Mon, 10 Aug 2026 12:00:58 -0400 Subject: [PATCH] feat(ui): add generated image actions (#77017) Give Control UI managed images bounded previews and shared full-image Open, Download, and Copy actions. Keep artifact access transcript-bound; the existing ticket is intentionally attachment-scoped to the lower-fidelity thumbnail. Co-authored-by: Ittiz Co-authored-by: Ayaan Zaidi --- docs/web/control-ui.md | 2 +- src/gateway/managed-image-actions.e2e.test.ts | 137 +++++++ src/gateway/managed-image-attachments.test.ts | 40 ++ src/gateway/managed-image-attachments.ts | 134 ++++++- ui/src/e2e/managed-image-actions.e2e.test.ts | 142 ++++++++ ui/src/i18n/locales/en.ts | 5 + .../chat/components/chat-message-images.ts | 343 +++++++++++++----- .../chat-message-media-lifecycle.test.ts | 33 +- .../chat/components/chat-message.test.ts | 130 ++++--- ui/src/styles/chat/layout.css | 76 +++- 10 files changed, 887 insertions(+), 155 deletions(-) create mode 100644 src/gateway/managed-image-actions.e2e.test.ts create mode 100644 ui/src/e2e/managed-image-actions.e2e.test.ts diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index a7d5c705d0d1..fc12b4e55e82 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -456,7 +456,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config - Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion. - `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`). - When a visible assistant message was truncated in `chat.history`, **Show more** fetches the full display-normalized transcript entry inline through `chat.message.get` by `sessionKey`, active `agentId` when needed, and transcript `messageId`. **Show less** restores the preview without discarding the fetched content. If the Gateway cannot return more, the message shows an explicit retryable error instead of silently repeating the truncated preview. - - Assistant/generated images are persisted as managed media references. New clients resolve their stable artifact ids through authenticated `artifacts.download` and receive short-lived, exact-resource media URLs, so reloads do not depend on raw base64 payloads or reusable credentials in image URLs. + - Assistant/generated images are persisted as managed media references. New clients resolve their stable artifact ids through authenticated `artifacts.download` and receive short-lived, exact-resource media URLs, so reloads do not depend on raw base64 payloads or reusable credentials in image URLs. The chat uses bounded thumbnails and provides Open, Download, and Copy actions for the full image. - When rendering `chat.history`, the Control UI strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `...`, `...`, `...`, `...`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens. It omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply` or the heartbeat acknowledgement token `HEARTBEAT_OK`. - During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up. - Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat). diff --git a/src/gateway/managed-image-actions.e2e.test.ts b/src/gateway/managed-image-actions.e2e.test.ts new file mode 100644 index 000000000000..2ac27c0b6dc2 --- /dev/null +++ b/src/gateway/managed-image-actions.e2e.test.ts @@ -0,0 +1,137 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { readImageProbeFromHeader } from "../media/image-ops.js"; +import { + createManagedOutgoingMediaBlocks, + MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX, +} from "./managed-image-attachments.js"; +import { connectGatewayClient, disconnectGatewayClient } from "./test-helpers.e2e.js"; +import { + installGatewayTestHooks, + testState, + withGatewayServer, + writeSessionStore, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +const GATEWAY_TOKEN = "managed-image-actions-e2e-token"; +const SESSION_KEY = "agent:main:main"; + +describe("managed image actions Gateway E2E", () => { + test("issues one transcript ticket for full and thumbnail image bytes", async () => { + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR is required for managed image E2E fixtures"); + } + testState.gatewayAuth = { mode: "token", token: GATEWAY_TOKEN }; + testState.sessionStorePath = path.join(stateDir, "sessions.sqlite"); + + const source = await fs.readFile( + path.join(process.cwd(), "docs/assets/openclaw-banner-dark.png"), + ); + const messageId = "managed-image-actions-message"; + const blocks = await createManagedOutgoingMediaBlocks({ + sessionKey: SESSION_KEY, + messageId, + mediaUrls: [`data:image/png;base64,${source.toString("base64")}`], + stateDir, + }); + const block = blocks.find( + (candidate) => + candidate.type === "image" && + typeof candidate.artifactId === "string" && + candidate.artifactId.startsWith(MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX), + ); + if (!block || typeof block.artifactId !== "string" || typeof block.url !== "string") { + throw new Error("managed image fixture did not produce an artifact"); + } + + const sessionId = "managed-image-actions-session"; + const transcriptPath = path.join(stateDir, `${sessionId}.jsonl`); + const timestamp = new Date().toISOString(); + await fs.writeFile( + transcriptPath, + [ + { type: "session", version: 3, id: sessionId, timestamp, cwd: stateDir }, + { + type: "message", + id: messageId, + parentId: null, + timestamp, + message: { + role: "assistant", + content: blocks, + timestamp: Date.now(), + __openclaw: { id: messageId }, + }, + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + "utf8", + ); + await writeSessionStore({ + entries: { + [SESSION_KEY]: { + sessionId, + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + }); + + await withGatewayServer( + async ({ port }) => { + const client = await connectGatewayClient({ + url: `ws://127.0.0.1:${port}`, + token: GATEWAY_TOKEN, + scopes: ["operator.read"], + }); + try { + const download = await client.request<{ + artifact?: { id?: string; source?: string }; + url?: string; + expiresAt?: string; + }>("artifacts.download", { + sessionKey: SESSION_KEY, + artifactId: block.artifactId, + }); + expect(download.artifact).toMatchObject({ + id: block.artifactId, + source: "session-transcript", + }); + expect(download.expiresAt).toEqual(expect.any(String)); + const fullUrl = new URL(download.url ?? "", `http://127.0.0.1:${port}`); + expect(fullUrl.searchParams.get("mediaTicket")).toMatch(/^v1\./u); + + const full = await fetch(fullUrl); + expect(full.status).toBe(200); + const fullBytes = Buffer.from(await full.arrayBuffer()); + expect(fullBytes).toEqual(source); + + const thumbnailUrl = new URL(fullUrl); + thumbnailUrl.pathname = thumbnailUrl.pathname.replace(/\/full$/u, "/thumbnail"); + const thumbnail = await fetch(thumbnailUrl); + expect(thumbnail.status).toBe(200); + expect(thumbnail.headers.get("content-type")).toBe("image/png"); + const thumbnailBytes = Buffer.from(await thumbnail.arrayBuffer()); + expect(readImageProbeFromHeader(thumbnailBytes)).toMatchObject({ + width: 300, + height: 84, + }); + + const authenticated = await fetch(new URL(block.url, fullUrl), { + headers: { Authorization: `Bearer ${GATEWAY_TOKEN}` }, + }); + expect(authenticated.status).toBe(200); + expect(Buffer.from(await authenticated.arrayBuffer())).toEqual(source); + } finally { + await disconnectGatewayClient(client); + } + }, + { serverOptions: { auth: { mode: "token", token: GATEWAY_TOKEN } } }, + ); + }); +}); diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index 34bd88e4502a..575048781728 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -16,6 +16,7 @@ import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/sess import { resolveExistingAgentSessionStoreTargetsReadOnlyResult } from "../config/sessions/targets-read-availability.js"; import { createPinnedLookup } from "../infra/net/ssrf.js"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { readImageProbeFromHeader } from "../media/image-ops.js"; import { setMediaStoreNetworkDepsForTest } from "../media/store.test-support.js"; import { closeOpenClawAgentDatabasesForTest, @@ -911,6 +912,45 @@ describe("handleManagedOutgoingImageHttpRequest", () => { expect(authorizeGatewayHttpRequestOrReplyMock).toHaveBeenCalledTimes(1); }); + it("serves a bounded thumbnail through the full-image artifact ticket", async () => { + const source = createSolidPngBuffer(640, 320, { r: 24, g: 64, b: 128 }); + const { attachmentId, sessionKey } = await createFixture(stateDir, { body: source }); + const canonicalPath = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`; + const transcriptMessages = [ + { + role: "assistant", + content: [{ type: "image", url: canonicalPath, openUrl: canonicalPath }], + __openclaw: { id: "msg-1" }, + }, + ]; + loadSessionEntryMock.mockReturnValue({ + storePath: path.join(stateDir, "sessions.sqlite"), + entry: { sessionId: "sess-1", sessionFile: "session.jsonl" }, + }); + resolveSessionHistoryTranscriptPathMock.mockResolvedValue("session.jsonl"); + readSessionMessagesMock.mockResolvedValue(transcriptMessages); + const download = await resolveManagedOutgoingImageArtifactDownload({ + sessionKey, + artifactId: `${MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX}${attachmentId}`, + stateDir, + }); + const thumbnailUrl = download?.url.replace(/\/full(?=\?)/u, "/thumbnail") ?? ""; + + vi.clearAllMocks(); + const { result } = await requestManagedImage({ + stateDir, + pathName: thumbnailUrl, + denyAuth: true, + transcriptMessages, + }); + + expect(result.statusCode).toBe(200); + expect(result.headers["content-type"]).toBe("image/png"); + expect(result.headers["content-disposition"]).toContain("cat-thumbnail.png"); + expect(readImageProbeFromHeader(result.body)).toMatchObject({ width: 300, height: 150 }); + expect(authorizeGatewayHttpRequestOrReplyMock).not.toHaveBeenCalled(); + }); + it("keeps serving and deleting an original after the configured media root changes", async () => { const fixture = await createFixture(stateDir); const externalConfigDir = tempDirs.make("managed-image-moved-config-"); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 36e1c3204d2a..03d48d22aa37 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -11,6 +11,7 @@ import { asDateTimestampMs, resolveTimestampMsToIsoString, } from "@openclaw/normalization-core/number-coercion"; +import pLimit from "p-limit"; import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import type { ReplyMediaAttachment } from "../auto-reply/reply-payload.js"; import { getRuntimeConfig } from "../config/config.js"; @@ -76,9 +77,17 @@ const MANAGED_OUTGOING_IMAGE_TICKET_SCOPE = "managed-outgoing-image"; export const MANAGED_OUTGOING_IMAGE_TICKET_TTL_MS = 5 * 60 * 1000; export const MANAGED_OUTGOING_IMAGE_ARTIFACT_ID_PREFIX = "artifact_managed_image_"; export const MANAGED_OUTGOING_MEDIA_ARTIFACT_ID_PREFIX = "artifact_managed_media_"; +const MANAGED_IMAGE_THUMBNAIL_MAX_SIDE = 300; +const MANAGED_IMAGE_THUMBNAIL_CACHE_MAX_ENTRIES = 128; +const MANAGED_IMAGE_THUMBNAIL_CACHE_MAX_BYTES = 16 * 1024 * 1024; +const MANAGED_IMAGE_THUMBNAIL_MAX_PENDING = 128; const MANAGED_OUTGOING_ATTACHMENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const managedOutgoingImageTicketSecret = randomBytes(32); +const managedImageThumbnailCache = new Map(); +const managedImageThumbnailJobs = new Map>(); +const limitManagedImageThumbnails = pLimit(4); +let managedImageThumbnailCacheBytes = 0; export const DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS = { maxBytes: 12 * 1024 * 1024, @@ -160,6 +169,64 @@ export type ManagedOutgoingMediaArtifactDownload = { url: string; expiresAt: string; }; + +function readManagedImageThumbnail(cacheKey: string): Buffer | undefined { + const thumbnail = managedImageThumbnailCache.get(cacheKey); + if (!thumbnail) { + return undefined; + } + managedImageThumbnailCache.delete(cacheKey); + managedImageThumbnailCache.set(cacheKey, thumbnail); + return thumbnail; +} + +function cacheManagedImageThumbnail(cacheKey: string, thumbnail: Buffer): void { + const previous = managedImageThumbnailCache.get(cacheKey); + if (previous) { + managedImageThumbnailCache.delete(cacheKey); + managedImageThumbnailCacheBytes -= previous.byteLength; + } + managedImageThumbnailCache.set(cacheKey, thumbnail); + managedImageThumbnailCacheBytes += thumbnail.byteLength; + while ( + managedImageThumbnailCache.size > MANAGED_IMAGE_THUMBNAIL_CACHE_MAX_ENTRIES || + managedImageThumbnailCacheBytes > MANAGED_IMAGE_THUMBNAIL_CACHE_MAX_BYTES + ) { + const oldest = managedImageThumbnailCache.entries().next().value; + if (!oldest) { + break; + } + managedImageThumbnailCache.delete(oldest[0]); + managedImageThumbnailCacheBytes -= oldest[1].byteLength; + } +} + +async function resolveManagedImageThumbnail( + cacheKey: string, + create: () => Promise, +): Promise { + const cached = readManagedImageThumbnail(cacheKey); + if (cached) { + return cached; + } + const active = managedImageThumbnailJobs.get(cacheKey); + if (active) { + return await active; + } + if (limitManagedImageThumbnails.pendingCount >= MANAGED_IMAGE_THUMBNAIL_MAX_PENDING) { + throw new Error("managed image thumbnail queue is full"); + } + const pending = limitManagedImageThumbnails(create) + .then((thumbnail) => { + cacheManagedImageThumbnail(cacheKey, thumbnail); + return thumbnail; + }) + .finally(() => { + managedImageThumbnailJobs.delete(cacheKey); + }); + managedImageThumbnailJobs.set(cacheKey, pending); + return await pending; +} type SessionManagedOutgoingAttachmentTranscriptStat = Omit< SessionManagedOutgoingAttachmentIndexCacheEntry, "index" @@ -1491,7 +1558,9 @@ export async function handleManagedOutgoingMediaHttpRequest( }, ): Promise { const requestUrl = new URL(req.url ?? "/", "http://localhost"); - const match = requestUrl.pathname.match(/^\/api\/chat\/media\/outgoing\/([^/]+)\/([^/]+)\/full$/); + const match = requestUrl.pathname.match( + /^\/api\/chat\/media\/outgoing\/([^/]+)\/([^/]+)\/(full|thumbnail)$/, + ); if (!match) { return false; } @@ -1503,7 +1572,8 @@ export async function handleManagedOutgoingMediaHttpRequest( const encodedSessionKey = match[1]; const attachmentId = match[2]; - if (!encodedSessionKey || !attachmentId) { + const variant = match[3]; + if (!encodedSessionKey || !attachmentId || (variant !== "full" && variant !== "thumbnail")) { return false; } if (!MANAGED_OUTGOING_ATTACHMENT_ID_RE.test(attachmentId)) { @@ -1555,19 +1625,15 @@ export async function handleManagedOutgoingMediaHttpRequest( return true; } } - const record = readManagedImageRecord(attachmentId, opts.stateDir); + const stateDir = opts.stateDir ?? resolveStateDir(); + const record = readManagedImageRecord(attachmentId, stateDir); if (!record || record.sessionKey !== sessionKey) { sendStatus(res, 404, "not found"); return true; } if ( - (await recordMatchesTranscriptMessage( - record, - undefined, - undefined, - undefined, - opts.stateDir, - )) !== "match" + (await recordMatchesTranscriptMessage(record, undefined, undefined, undefined, stateDir)) !== + "match" ) { sendStatus(res, 404, "not found"); return true; @@ -1583,11 +1649,57 @@ export async function handleManagedOutgoingMediaHttpRequest( return true; } const respondNotFound = () => sendStatus(res, 404, "not found"); - let byteStream = createGatewayByteStream(res, opened.handle, respondNotFound); let responseContentType = record.original.contentType || "application/octet-stream"; let responseFilename = record.original.filename; const mediaKind = resolveManagedRecordKind(record); + if (variant === "thumbnail") { + if (mediaKind !== "image") { + await opened.handle.close(); + sendStatus(res, 404, "not found"); + return true; + } + try { + // A full-image ticket already authorizes these original bytes; the thumbnail + // is a lower-fidelity representation of the same transcript attachment. + const cacheKey = `${opened.realPath}\0${opened.stat.mtimeMs}\0${opened.stat.size}`; + const thumbnail = await resolveManagedImageThumbnail(cacheKey, async () => { + const source = await opened.handle.readFile(); + return ( + await createImageProcessor().encode(source, { + format: "png", + resize: { maxSide: MANAGED_IMAGE_THUMBNAIL_MAX_SIDE, enlarge: false }, + compressionLevel: 8, + }) + ).data; + }); + await opened.handle.close(); + const sourceName = path.parse(responseFilename ?? "generated-image").name; + res.statusCode = 200; + res.setHeader("content-type", "image/png"); + res.setHeader("content-length", String(thumbnail.byteLength)); + res.setHeader("x-content-type-options", "nosniff"); + res.setHeader("referrer-policy", "no-referrer"); + res.setHeader( + "cache-control", + hasValidMediaTicket + ? `private, max-age=${MANAGED_OUTGOING_IMAGE_TICKET_TTL_MS / 1000}, immutable` + : "private, max-age=31536000, immutable", + ); + res.setHeader( + "content-disposition", + buildManagedMediaContentDisposition(`${sourceName}-thumbnail.png`, "image/png"), + ); + res.end(req.method === "HEAD" ? undefined : thumbnail); + return true; + } catch { + await opened.handle.close().catch(() => {}); + sendStatus(res, 404, "not found"); + return true; + } + } + + let byteStream = createGatewayByteStream(res, opened.handle, respondNotFound); if ( requestUrl.searchParams.get("playback") === "1" && (mediaKind === "audio" || mediaKind === "video") diff --git a/ui/src/e2e/managed-image-actions.e2e.test.ts b/ui/src/e2e/managed-image-actions.e2e.test.ts new file mode 100644 index 000000000000..296b242914c5 --- /dev/null +++ b/ui/src/e2e/managed-image-actions.e2e.test.ts @@ -0,0 +1,142 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("previews, downloads, copies, and opens a ticketed generated image", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const attachmentId = crypto.randomUUID(); + const artifactId = `artifact_managed_image_${attachmentId}`; + const imageUrl = `/api/chat/media/outgoing/agent%3Amain%3Amain/${attachmentId}/full`; + const ticketedUrl = `${imageUrl}?mediaTicket=ticket-e2e`; + const imageBytes = await readFile( + path.join(process.cwd(), "docs/assets/openclaw-banner-dark.png"), + ); + const requestedVariants: string[] = []; + await page.addInitScript(() => { + Object.defineProperty(globalThis, "copiedImage", { configurable: true, writable: true }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + write: async (items: ClipboardItem[]) => { + const blob = await items[0]?.getType("image/png"); + Object.defineProperty(globalThis, "copiedImage", { + configurable: true, + value: blob ? { size: blob.size, type: blob.type } : null, + writable: true, + }); + }, + }, + }); + }); + await page.route("**/api/chat/media/outgoing/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + expect(url.searchParams.get("mediaTicket")).toBe("ticket-e2e"); + expect(request.headers().authorization).toBeUndefined(); + requestedVariants.push(url.pathname.split("/").at(-1) ?? ""); + await route.fulfill({ body: imageBytes, contentType: "image/png" }); + }); + const gateway = await installMockGateway(page, { + historyMessages: [ + { + role: "assistant", + content: [ + { + type: "image", + artifactId, + url: imageUrl, + alt: "Ticketed generated image", + mimeType: "image/png", + width: 1280, + height: 358, + }, + ], + timestamp: Date.now(), + }, + ], + methodResponses: { + "artifacts.download": { + artifact: { + id: artifactId, + type: "image", + title: "Ticketed generated image", + mimeType: "image/png", + download: { mode: "url" }, + }, + url: ticketedUrl, + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const image = page.getByAltText("Ticketed generated image"); + await image.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => + image.evaluate((element) => + element instanceof HTMLImageElement && element.complete ? element.naturalWidth : 0, + ), + ) + .toBe(1280); + expect(requestedVariants).toEqual(["thumbnail"]); + + await page.locator(".chat-image-frame").hover(); + const downloadButton = page.getByRole("button", { name: "Download image" }); + await expect + .poll(() => + downloadButton.evaluate((button) => { + const rect = button.getBoundingClientRect(); + const hit = document.elementFromPoint( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + ); + return { + hit: hit instanceof Node && button.contains(hit), + target: + hit instanceof Element + ? `${hit.tagName.toLowerCase()}.${Array.from(hit.classList).join(".")}` + : null, + pointerEvents: getComputedStyle(button).pointerEvents, + }; + }), + ) + .toMatchObject({ hit: true, pointerEvents: "auto" }); + const download = page.waitForEvent("download"); + await downloadButton.click(); + expect((await download).suggestedFilename()).toBe("Ticketed generated image.png"); + + await page.getByRole("button", { name: "Copy image" }).click(); + await expect + .poll(() => + page.evaluate( + () => + (globalThis as { copiedImage?: { size: number; type: string } }).copiedImage ?? null, + ), + ) + .toEqual({ size: imageBytes.byteLength, type: "image/png" }); + await expect + .poll(() => page.locator("openclaw-toast-host").textContent()) + .toContain("Copied!"); + + await page.locator('.chat-image-action[title="Open original"]').click(); + await page + .getByRole("dialog", { name: "Image preview: Ticketed generated image" }) + .waitFor({ state: "visible" }); + expect(requestedVariants).toEqual(["thumbnail", "full"]); + expect(await gateway.getRequests("artifacts.download")).toHaveLength(2); + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 8e031618375d..12090ac0f9d2 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4896,6 +4896,11 @@ export const en: TranslationMap = { label: "Image preview: {title}", open: "Open image {title}", openOriginal: "Open original", + copy: "Copy image", + download: "Download image", + copyFailed: "Could not copy this image. Check clipboard access and try again.", + downloadFailed: "Could not download this image. Try again.", + loadFailed: "Could not load this image. Try again.", close: "Close image preview", untitled: "Image", }, diff --git a/ui/src/pages/chat/components/chat-message-images.ts b/ui/src/pages/chat/components/chat-message-images.ts index 03e51bfd021e..9373dcb24d67 100644 --- a/ui/src/pages/chat/components/chat-message-images.ts +++ b/ui/src/pages/chat/components/chat-message-images.ts @@ -1,12 +1,14 @@ import { html, noChange, nothing, type TemplateResult } from "lit"; import { AsyncDirective, directive } from "lit/async-directive.js"; import { until } from "lit/directives/until.js"; +import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; import { openExternalUrlSafe, reserveExternalWindowForDeferredNavigation, resolveSafeExternalUrl, } from "../../../lib/open-external-url.ts"; +import { showToast } from "../../../lib/toast.ts"; import { resolveAssistantAttachmentAvailability } from "./chat-message-attachment-availability.ts"; import { openResolvedImage } from "./chat-message-image-open.ts"; import { @@ -33,6 +35,7 @@ import { const MANAGED_OUTGOING_IMAGE_FETCH_TIMEOUT_MS = 30_000; const MANAGED_OUTGOING_IMAGE_RETRY_MS = 5_000; +type ManagedImageVariant = "full" | "thumbnail"; class ManagedImageResourceDirective extends AsyncDirective { private cacheKey: string | undefined; @@ -146,68 +149,81 @@ export function renderMessageImages(images: RenderableImageBlock[], opts?: Image const openImage = (img: RenderableImageBlock, previewUrl: string) => { const title = img.alt?.trim() || t("chat.imageLightbox.untitled"); const requestVersion = opts?.onRequestOpenImage?.(); - const managedSource = isManagedOutgoingImageSource(img.displayUrl); - const cacheKey = managedSource - ? resolveManagedOutgoingImageBlobUrlCacheKey(img.displayUrl, opts, img.artifactId) - : undefined; - const previewIsCurrent = - !managedSource || - readManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId) === previewUrl; - if (previewIsCurrent) { - const release = - opts?.onOpenImage && cacheKey ? retainManagedImageBlobUrl(cacheKey) : undefined; - openResolvedImage(opts?.onOpenImage, previewUrl, title, release, requestVersion); + if (!isManagedOutgoingImageSource(img.displayUrl)) { + openResolvedImage(opts?.onOpenImage, previewUrl, title, undefined, requestVersion); + return; + } + + const cacheKey = resolveManagedOutgoingImageBlobUrlCacheKey( + img.displayUrl, + opts, + img.artifactId, + "full", + ); + const cached = readManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId, "full"); + if (cached) { + const release = opts?.onOpenImage ? retainManagedImageBlobUrl(cacheKey) : undefined; + openResolvedImage(opts?.onOpenImage, cached, title, release, requestVersion); return; } - // A managed-image Blob URL may have been evicted after this row rendered. - // Re-resolve before opening so the modal never receives a revoked URL. if (!opts?.onOpenImage) { const pendingWindow = reserveExternalWindowForDeferredNavigation(); - void resolveManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId) + void resolveManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId, "full") .then((freshUrl) => { const safeUrl = freshUrl ? resolveSafeExternalUrl(freshUrl, window.location.href, { allowDataImage: true }) : null; if (!safeUrl) { pendingWindow?.close(); + showToast({ message: t("chat.imageLightbox.loadFailed") }); } else if (pendingWindow) { pendingWindow.location.replace(safeUrl); } else { openExternalUrlSafe(safeUrl, { allowDataImage: true }); } }) - .catch(() => pendingWindow?.close()); + .catch(() => { + pendingWindow?.close(); + showToast({ message: t("chat.imageLightbox.loadFailed") }); + }); return; } - void resolveManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId) + void resolveManagedOutgoingImageBlobUrl(img.displayUrl, opts, img.artifactId, "full") .then((freshUrl) => { if (!freshUrl) { + showToast({ message: t("chat.imageLightbox.loadFailed") }); return; } const release = cacheKey ? retainManagedImageBlobUrl(cacheKey) : undefined; openResolvedImage(opts.onOpenImage, freshUrl, title, release, requestVersion); }) - .catch(() => {}); + .catch(() => showToast({ message: t("chat.imageLightbox.loadFailed") })); }; const renderImageElement = (img: RenderableImageBlock, previewUrl: string) => { const title = img.alt?.trim() || t("chat.imageLightbox.untitled"); + const managed = isManagedOutgoingImageSource(img.displayUrl); return html` - + + + ${managed + ? renderManagedImageActions(img, opts, () => openImage(img, previewUrl)) + : nothing} + `; }; @@ -252,18 +268,20 @@ function resolveManagedOutgoingImageBlobUrlCacheKey( source: string, opts?: ImageRenderOptions, artifactId?: string, + variant: ManagedImageVariant = "thumbnail", ): string { const authToken = opts?.authToken?.trim() ?? ""; - return `${source}::${authToken}::${artifactId?.trim() ?? ""}`; + return `${buildManagedOutgoingImageVariantUrl(source, variant)}::${authToken}::${artifactId?.trim() ?? ""}`; } function readManagedOutgoingImageBlobUrl( source: string, opts?: ImageRenderOptions, artifactId?: string, + variant: ManagedImageVariant = "thumbnail", ): string | undefined { return readManagedImageBlobUrl( - resolveManagedOutgoingImageBlobUrlCacheKey(source, opts, artifactId), + resolveManagedOutgoingImageBlobUrlCacheKey(source, opts, artifactId, variant), ); } @@ -271,14 +289,14 @@ async function resolveManagedOutgoingImageBlobUrl( source: string, opts?: ImageRenderOptions, artifactId?: string, + variant: ManagedImageVariant = "thumbnail", ): Promise { - const authToken = opts?.authToken?.trim() ?? ""; - const cacheKey = resolveManagedOutgoingImageBlobUrlCacheKey(source, opts, artifactId); + const cacheKey = resolveManagedOutgoingImageBlobUrlCacheKey(source, opts, artifactId, variant); const resource = observeChatMediaResource( "managed-image", cacheKey, opts?.onRequestUpdate, - `${source}::${artifactId?.trim() ?? ""}`, + `${buildManagedOutgoingImageVariantUrl(source, variant)}::${artifactId?.trim() ?? ""}`, ); const cached = readManagedImageBlobUrl(cacheKey); if (cached) { @@ -302,61 +320,25 @@ async function resolveManagedOutgoingImageBlobUrl( const controller = new AbortController(); resource.abortController = controller; const pending = (async () => { - const requesterSessionKey = resolveManagedOutgoingImageRequesterSessionKey(source); - const artifactDownload = - requesterSessionKey && artifactId && opts?.resolveArtifactDownload - ? await opts - .resolveArtifactDownload({ sessionKey: requesterSessionKey, artifactId }) - .catch(() => null) - : null; + const blob = await fetchManagedOutgoingImageBlob( + source, + opts, + artifactId, + variant, + controller, + ); + if (!blob) { + return markManagedOutgoingImageUnavailable(resource); + } if (!isChatMediaResourceCurrent(resource)) { return null; } - const requestUrl = artifactDownload?.url ?? source; - const headers = new Headers({ Accept: "image/*" }); - if (!artifactDownload && authToken) { - headers.set("Authorization", `Bearer ${authToken}`); - } - if (!artifactDownload && requesterSessionKey) { - headers.set("x-openclaw-requester-session-key", requesterSessionKey); - } - const timeout = setTimeout(() => { - controller.abort( - new DOMException("managed outgoing image fetch timed out", "TimeoutError"), - ); - }, MANAGED_OUTGOING_IMAGE_FETCH_TIMEOUT_MS); - try { - // Managed media is a Gateway API at the origin root. Rebasing it under - // the Control UI mount path serves the HTML shell instead of image bytes. - const res = await fetch(requestUrl, { - method: "GET", - headers, - credentials: "same-origin", - signal: controller.signal, - }); - if (!res.ok) { - return markManagedOutgoingImageUnavailable(resource); - } - const blob = await res.blob(); - if (!blob.type.startsWith("image/")) { - return markManagedOutgoingImageUnavailable(resource); - } - if (!isChatMediaResourceCurrent(resource)) { - return null; - } - const blobUrl = URL.createObjectURL(blob); - cacheManagedImageBlobUrl(cacheKey, blobUrl); - resource.value = blobUrl; - resource.retryAttempted = false; - resource.unavailableAt = undefined; - return blobUrl; - } catch { - // The render path treats a missing preview as `nothing`; never reject - // its `until` promise for an optional image fetch or body failure. - return markManagedOutgoingImageUnavailable(resource); - } finally { - clearTimeout(timeout); - } + const blobUrl = URL.createObjectURL(blob); + cacheManagedImageBlobUrl(cacheKey, blobUrl); + resource.value = blobUrl; + resource.retryAttempted = false; + resource.unavailableAt = undefined; + return blobUrl; })().finally(() => { if (resource.abortController === controller) { resource.abortController = undefined; @@ -372,6 +354,191 @@ async function resolveManagedOutgoingImageBlobUrl( return resource.pending; } +function buildManagedOutgoingImageVariantUrl(source: string, variant: ManagedImageVariant): string { + try { + const parsed = new URL(source, window.location.origin); + parsed.pathname = parsed.pathname.replace(/\/(?:full|thumbnail)$/u, `/${variant}`); + return /^https?:\/\//iu.test(source) + ? parsed.href + : `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return source.replace(/\/(?:full|thumbnail)(?=$|[?#])/u, `/${variant}`); + } +} + +async function fetchManagedOutgoingImageBlob( + source: string, + opts: ImageRenderOptions | undefined, + artifactId: string | undefined, + variant: ManagedImageVariant, + controller = new AbortController(), +): Promise { + const requesterSessionKey = resolveManagedOutgoingImageRequesterSessionKey(source); + const artifactDownload = + requesterSessionKey && artifactId && opts?.resolveArtifactDownload + ? await opts + .resolveArtifactDownload({ sessionKey: requesterSessionKey, artifactId }) + .catch(() => null) + : null; + const requestUrl = buildManagedOutgoingImageVariantUrl(artifactDownload?.url ?? source, variant); + const headers = new Headers({ Accept: "image/*" }); + const authToken = opts?.authToken?.trim(); + if (!artifactDownload && authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } + if (!artifactDownload && requesterSessionKey) { + headers.set("x-openclaw-requester-session-key", requesterSessionKey); + } + const timeout = globalThis.setTimeout(() => { + controller.abort(new DOMException("managed outgoing image fetch timed out", "TimeoutError")); + }, MANAGED_OUTGOING_IMAGE_FETCH_TIMEOUT_MS); + try { + // Managed media is a Gateway API at the origin root. Rebasing it under + // the Control UI mount path serves the HTML shell instead of image bytes. + const response = await fetch(requestUrl, { + method: "GET", + headers, + credentials: "same-origin", + signal: controller.signal, + }); + if (!response.ok) { + return null; + } + const blob = await response.blob(); + return blob.type.startsWith("image/") ? blob : null; + } catch { + return null; + } finally { + globalThis.clearTimeout(timeout); + } +} + +async function readManagedOutgoingImageBlob( + source: string, + opts?: ImageRenderOptions, + artifactId?: string, +): Promise { + const blobUrl = await resolveManagedOutgoingImageBlobUrl(source, opts, artifactId, "full"); + if (!blobUrl) { + throw new Error("managed image is unavailable"); + } + const response = await fetch(blobUrl); + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) { + throw new Error("managed image response is invalid"); + } + return blob; +} + +function imageDownloadFileName(title: string, mimeType: string): string { + const extension = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/", 2)[1] || "img"; + const stem = Array.from(title, (character) => + character.codePointAt(0)! <= 0x1f || '<>:"/\\|?*'.includes(character) ? "-" : character, + ) + .join("") + .replace(/\.[a-z0-9]{1,10}$/iu, "") + .replace(/[. -]+$/u, "") + .slice(0, 120); + return `${stem || "generated-image"}.${/^[a-z0-9.+-]{1,12}$/u.test(extension) ? extension : "img"}`; +} + +function downloadImageBlob(blob: Blob, fileName: string): void { + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = fileName; + anchor.click(); + globalThis.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000); +} + +async function convertImageBlobToPng(blob: Blob): Promise { + if (blob.type === "image/png") { + return blob; + } + const bitmap = await createImageBitmap(blob); + try { + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("image conversion context is unavailable"); + } + context.drawImage(bitmap, 0, 0); + return await new Promise((resolve, reject) => { + canvas.toBlob( + (converted) => + converted ? resolve(converted) : reject(new Error("image conversion failed")), + "image/png", + ); + }); + } finally { + bitmap.close(); + } +} + +function renderManagedImageActions( + image: RenderableImageBlock, + opts: ImageRenderOptions | undefined, + onOpen: () => void, +) { + const title = image.alt?.trim() || t("chat.imageLightbox.untitled"); + const download = async () => { + try { + const blob = await readManagedOutgoingImageBlob(image.displayUrl, opts, image.artifactId); + downloadImageBlob(blob, imageDownloadFileName(title, blob.type)); + } catch { + showToast({ message: t("chat.imageLightbox.downloadFailed") }); + } + }; + const copy = async () => { + try { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error("image clipboard is unavailable"); + } + const png = readManagedOutgoingImageBlob(image.displayUrl, opts, image.artifactId).then( + convertImageBlobToPng, + ); + void png.catch(() => {}); + await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]); + showToast({ message: t("common.copied") }); + } catch { + showToast({ message: t("chat.imageLightbox.copyFailed") }); + } + }; + return html` + + + + + + `; +} + function markManagedOutgoingImageUnavailable(resource: ChatMediaResource): null { if (!isChatMediaResourceCurrent(resource)) { return null; diff --git a/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts b/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts index a9cd370e4829..42ec117dead7 100644 --- a/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts +++ b/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts @@ -37,6 +37,10 @@ function managedImageSource(): string { return `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`; } +function managedImageResourceKey(source: string): string { + return `${source.replace(/\/full$/u, "/thumbnail")}::::`; +} + function installManagedImageUrls(): string { const NativeUrl = URL; const blobUrl = `blob:managed-image-${crypto.randomUUID()}`; @@ -220,7 +224,9 @@ describe("chat media resource lifecycle", () => { for (const source of sources) { currentSource = source; rerender(); - resources.push(observeChatMediaResource("managed-image", `${source}::::`)); + resources.push( + observeChatMediaResource("managed-image", managedImageResourceKey(source)), + ); await vi.advanceTimersByTimeAsync(0); } @@ -251,11 +257,11 @@ describe("chat media resource lifecycle", () => { rerender(); const firstResource = observeChatMediaResource( "managed-image", - `${firstSource}::::`, + managedImageResourceKey(firstSource), ); const secondResource = observeChatMediaResource( "managed-image", - `${secondSource}::::`, + managedImageResourceKey(secondSource), ); await vi.advanceTimersByTimeAsync(0); @@ -291,7 +297,7 @@ describe("chat media resource lifecycle", () => { await vi.advanceTimersByTimeAsync(0); const originalResource = observeChatMediaResource( "managed-image", - `${source}::::`, + managedImageResourceKey(source), ); expect(originalResource.subscribers.size).toBe(1); @@ -303,7 +309,7 @@ describe("chat media resource lifecycle", () => { const reconnectedResource = observeChatMediaResource( "managed-image", - `${source}::::`, + managedImageResourceKey(source), ); expect(reconnectedResource.subscribers.size).toBe(1); expect(renderImageRow).toHaveBeenCalledTimes(1); @@ -361,7 +367,10 @@ describe("chat media resource lifecycle", () => { const sources = Array.from({ length: 65 }, () => managedImageSource()); const resources = sources.map((source) => { renderManagedImage(document.createElement("div"), source); - return observeChatMediaResource("managed-image", `${source}::::`); + return observeChatMediaResource( + "managed-image", + managedImageResourceKey(source), + ); }); await vi.advanceTimersByTimeAsync(0); @@ -375,8 +384,10 @@ describe("chat media resource lifecycle", () => { throw new Error("expected the oldest and newest managed images"); } expect(isChatMediaResourceCurrent(oldestResource)).toBe(false); - expect(readManagedImageBlobUrl(`${oldestSource}::::`)).toBeUndefined(); - expect(readManagedImageBlobUrl(`${latestSource}::::`)).toBe("blob:bounded-managed-image-64"); + expect(readManagedImageBlobUrl(managedImageResourceKey(oldestSource))).toBeUndefined(); + expect(readManagedImageBlobUrl(managedImageResourceKey(latestSource))).toBe( + "blob:bounded-managed-image-64", + ); expect(revokeObjectURL).toHaveBeenCalledWith("blob:bounded-managed-image-0"); renderManagedImage(document.createElement("div"), latestSource); @@ -386,7 +397,9 @@ describe("chat media resource lifecycle", () => { renderManagedImage(document.createElement("div"), oldestSource); await vi.advanceTimersByTimeAsync(0); expect(fetchMock).toHaveBeenCalledTimes(66); - expect(readManagedImageBlobUrl(`${oldestSource}::::`)).toBe("blob:bounded-managed-image-65"); + expect(readManagedImageBlobUrl(managedImageResourceKey(oldestSource))).toBe( + "blob:bounded-managed-image-65", + ); }); it("shares a managed image retry and wakes both subscribed split panes", async () => { @@ -807,7 +820,7 @@ describe("chat media resource lifecycle", () => { expect(resolveArtifactDownload).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2); for (const [requestUrl, init] of fetchMock.mock.calls as Array<[string, RequestInit]>) { - expect(requestUrl).toBe(ticketedUrl); + expect(requestUrl).toBe(ticketedUrl.replace(/\/full(?=\?)/u, "/thumbnail")); const headers = new Headers(init.headers); expect(headers.get("Authorization")).toBeNull(); expect(headers.get("x-openclaw-requester-session-key")).toBeNull(); diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 8027f71a5b1b..c53098a44b4b 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -4497,11 +4497,14 @@ describe("grouped chat rendering", () => { expect(image?.getAttribute("src")).toBe(objectUrl); expect(image?.getAttribute("alt")).toBe("Generated image 1"); }); - const [, fetchInit] = requireFetchCallForUrl(fetchMock, managedChatImageUrl); + const thumbnailUrl = managedChatImageUrl.replace(/\/full$/u, "/thumbnail"); + const [, fetchInit] = requireFetchCallForUrl(fetchMock, thumbnailUrl); expectSameOriginGet(fetchInit); expectElement(container, ".chat-message-image-button", HTMLButtonElement).click(); - expect(onOpenImage).toHaveBeenCalledWith( - expect.objectContaining({ src: objectUrl, title: "Generated image 1" }), + await vi.waitFor(() => + expect(onOpenImage).toHaveBeenCalledWith( + expect.objectContaining({ src: objectUrl, title: "Generated image 1" }), + ), ); const activeItem = onOpenImage.mock.calls[0]?.[0]; activeItem?.release?.(); @@ -4516,7 +4519,7 @@ describe("grouped chat rendering", () => { expiresAt: "2026-07-28T05:00:00.000Z", })); const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { - expect(url).toBe(ticketedUrl); + expect(url).toBe(ticketedUrl.replace(/\/full(?=\?)/u, "/thumbnail")); const headers = new Headers(init?.headers); expect(headers.get("Authorization")).toBeNull(); expect(headers.get("x-openclaw-requester-session-key")).toBeNull(); @@ -4540,6 +4543,59 @@ describe("grouped chat rendering", () => { sessionKey: "agent:main:main", artifactId, }); + expect(container.querySelectorAll(".chat-image-action")).toHaveLength(3); + }); + + it("reuses one full-image fetch for download and copy actions", async () => { + const attachmentId = crypto.randomUUID(); + const artifactId = `artifact_managed_image_${attachmentId}`; + const source = `/api/chat/media/outgoing/agent%3Amain%3Amain/${attachmentId}/full`; + const ticketedUrl = `${source}?mediaTicket=ticket`; + const thumbnailUrl = ticketedUrl.replace(/\/full(?=\?)/u, "/thumbnail"); + const resolveArtifactDownload = vi.fn(async () => ({ url: ticketedUrl })); + const objectUrls = ["blob:thumbnail", "blob:full", "blob:download"]; + const NativeUrl = URL; + vi.stubGlobal( + "URL", + class extends NativeUrl { + static override createObjectURL = vi.fn(() => objectUrls.shift() ?? "blob:extra"); + static override revokeObjectURL = vi.fn(); + }, + ); + const imageBlob = new Blob(["png"], { type: "image/png" }); + const fetchMock = vi.fn(async () => ({ ok: true, blob: async () => imageBlob })); + vi.stubGlobal("fetch", fetchMock); + let copiedBlob: Blob | undefined; + class ClipboardItemMock { + constructor(readonly values: Record>) {} + } + const write = vi.fn(async (items: ClipboardItemMock[]) => { + copiedBlob = await items[0]?.values["image/png"]; + }); + vi.stubGlobal("ClipboardItem", ClipboardItemMock); + vi.stubGlobal("navigator", { clipboard: { write } }); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + const toastHost = document.body.appendChild(document.createElement("openclaw-toast-host")); + const container = document.body.appendChild(document.createElement("div")); + renderAssistantMessage( + container, + createAssistantImageMessage(source, "Ticketed image", { artifactId }), + { showToolCalls: false, resolveArtifactDownload }, + ); + await vi.waitFor(() => expect(container.querySelector(".chat-message-image")).not.toBeNull()); + expect(fetchMock).toHaveBeenCalledWith(thumbnailUrl, expect.anything()); + + expectElement(container, 'button[aria-label="Download image"]', HTMLButtonElement).click(); + await vi.waitFor(() => expect(click).toHaveBeenCalledOnce()); + expect(click.mock.instances[0]?.download).toBe("Ticketed image.png"); + + expectElement(container, 'button[aria-label="Copy image"]', HTMLButtonElement).click(); + await vi.waitFor(() => expect(copiedBlob?.type).toBe("image/png")); + await vi.waitFor(() => expect(toastHost.textContent).toContain("Copied!")); + expect(fetchMock.mock.calls.filter(([url]) => url === ticketedUrl)).toHaveLength(1); + expect(resolveArtifactDownload).toHaveBeenCalledTimes(2); + toastHost.remove(); + container.remove(); }); it("aborts a stalled managed outgoing image fetch after the deadline", async () => { @@ -4571,7 +4627,10 @@ describe("grouped chat rendering", () => { ); expect(fetchMock).toHaveBeenCalledTimes(1); - const [, fetchInit] = requireFetchCallForUrl(fetchMock, managedChatImageUrl); + const [, fetchInit] = requireFetchCallForUrl( + fetchMock, + managedChatImageUrl.replace(/\/full$/u, "/thumbnail"), + ); expect(fetchInit?.signal?.aborted).toBe(false); expectSameOriginGet(fetchInit); @@ -4614,7 +4673,10 @@ describe("grouped chat rendering", () => { ); expect(fetchMock).toHaveBeenCalledTimes(1); - const [, fetchInit] = requireFetchCallForUrl(fetchMock, managedChatImageUrl); + const [, fetchInit] = requireFetchCallForUrl( + fetchMock, + managedChatImageUrl.replace(/\/full$/u, "/thumbnail"), + ); expect(fetchInit?.signal?.aborted).toBe(false); expectSameOriginGet(fetchInit); @@ -4666,7 +4728,7 @@ describe("grouped chat rendering", () => { | undefined; const response = { ok: true, blob: async () => new Blob(["png"], { type: "image/png" }) }; const fetchMock = vi.fn((url: string) => { - if (deferEvictedRefetch && url === imageUrls[1]) { + if (deferEvictedRefetch && url === imageUrls[1]?.replace(/\/full$/u, "/thumbnail")) { return new Promise((resolve) => { resolveEvictedRefetch = resolve; }); @@ -4676,29 +4738,15 @@ describe("grouped chat rendering", () => { vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const container = document.createElement("div"); - let activeRequestVersion = 0; - const acceptedImageOpen = vi.fn(); - const onRequestOpenImage = vi.fn(() => ++activeRequestVersion); - const onOpenImage = vi.fn((item: { release?: () => void }, requestVersion?: number) => { - if (requestVersion === activeRequestVersion) { - acceptedImageOpen(item); - } else { - item.release?.(); - } + renderAssistantMessage(container, { + role: "assistant", + content: imageUrls.slice(0, 64).map((url, index) => ({ + type: "image", + url, + alt: `Generated image ${index + 1}`, + })), + timestamp: Date.now(), }); - renderAssistantMessage( - container, - { - role: "assistant", - content: imageUrls.slice(0, 64).map((url, index) => ({ - type: "image", - url, - alt: `Generated image ${index + 1}`, - })), - timestamp: Date.now(), - }, - { onOpenImage, onRequestOpenImage }, - ); await vi.waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(64)); const recentContainer = document.createElement("div"); @@ -4722,26 +4770,20 @@ describe("grouped chat rendering", () => { expect(revokeObjectURL).toHaveBeenCalled(); deferEvictedRefetch = true; - const evictedImage = container.querySelector('img[alt="Generated image 2"]'); - const newestCurrentImage = container.querySelector( - 'img[alt="Generated image 3"]', - ); - expect(evictedImage).toBeInstanceOf(HTMLImageElement); - expect(newestCurrentImage).toBeInstanceOf(HTMLImageElement); - evictedImage!.click(); + const evictedContainer = document.createElement("div"); + renderAssistantMessage(evictedContainer, { + role: "assistant", + content: [{ type: "image", url: imageUrls[1], alt: "Refetched image" }], + timestamp: Date.now(), + }); await vi.waitFor(() => expect(resolveEvictedRefetch).toBeTypeOf("function")); - newestCurrentImage!.click(); - expect(acceptedImageOpen).toHaveBeenCalledWith( - expect.objectContaining({ title: "Generated image 3" }), - ); - + const createsBeforeRefetch = createObjectURL.mock.calls.length; resolveEvictedRefetch?.(response); await vi.waitFor(() => - expect(createObjectURL.mock.calls.length).toBeGreaterThan(createsBeforeOverflow), + expect(createObjectURL.mock.calls.length).toBeGreaterThan(createsBeforeRefetch), ); - expect(acceptedImageOpen).toHaveBeenCalledTimes(1); - (acceptedImageOpen.mock.calls[0]?.[0] as { release?: () => void } | undefined)?.release?.(); + expect(evictedContainer.querySelector(".chat-message-image")).not.toBeNull(); }); it("bounds managed outgoing image miss retention", async () => { diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index e34f5d241cca..18790989bfa3 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -936,9 +936,83 @@ openclaw-chat-page { cursor: zoom-in; } +.chat-image-frame { + position: relative; + display: inline-flex; + max-width: 100%; + overflow: hidden; + border-radius: var(--radius-md); +} + +.chat-image-frame--managed .chat-message-image-button { + align-items: center; + justify-content: center; + min-width: 110px; + min-height: 42px; +} + +.chat-image-actions { + position: absolute; + z-index: 1; + top: 6px; + right: 6px; + display: flex; + gap: 4px; + opacity: 0; + pointer-events: none; + transform: translateY(-2px); + transition: + opacity 120ms ease-out, + transform 120ms ease-out; +} + +.chat-image-frame:hover .chat-image-actions, +.chat-image-frame:focus-within .chat-image-actions { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +@media (hover: none) { + .chat-image-actions { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } +} + +.chat-image-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid rgb(255 255 255 / 22%); + border-radius: 8px; + color: #fff; + background: rgb(12 16 24 / 78%); + backdrop-filter: blur(8px); + cursor: pointer; +} + +.chat-image-action:hover { + background: rgb(12 16 24 / 94%); +} + +.chat-image-action:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.chat-image-action svg { + width: 15px; + height: 15px; +} + .chat-message-image-button:focus-visible { outline: 2px solid var(--focus-ring); - outline-offset: 3px; + outline-offset: -3px; } .chat-message-image {