From b5038fd9a17d459e91240622d3ca6ef59571924e Mon Sep 17 00:00:00 2001 From: Alec Hrdina Date: Sat, 18 Apr 2026 00:15:12 -0500 Subject: [PATCH] fix(ui): keep history-backed user image messages visible --- ui/src/ui/chat/grouped-render.ts | 87 ++++++++++++++++++++++++++------ ui/src/ui/views/chat.test.ts | 42 +++++++++++++-- 2 files changed, 110 insertions(+), 19 deletions(-) diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 22e4d6d56a8..a5d4682f23e 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -52,6 +52,18 @@ type ImageBlock = { alt?: string; }; +function appendImageBlock(images: ImageBlock[], block: ImageBlock) { + if (!images.some((entry) => entry.url === block.url && entry.alt === block.alt)) { + images.push(block); + } +} + +function buildBase64ImageUrl(params: { data: string; mediaType?: string }): string { + return params.data.startsWith("data:") + ? params.data + : `data:${params.mediaType ?? "image/png"};base64,${params.data}`; +} + function extractImages(message: unknown): ImageBlock[] { const m = message as Record; const content = m.content; @@ -68,24 +80,46 @@ function extractImages(message: unknown): ImageBlock[] { // Handle source object format (from sendChatMessage) const source = b.source as Record | undefined; if (source?.type === "base64" && typeof source.data === "string") { - const data = source.data; - const mediaType = (source.media_type as string) || "image/png"; - // If data is already a data URL, use it directly - const url = data.startsWith("data:") ? data : `data:${mediaType};base64,${data}`; - images.push({ url }); + appendImageBlock(images, { + url: buildBase64ImageUrl({ + data: source.data, + mediaType: typeof source.media_type === "string" ? source.media_type : undefined, + }), + }); } else if (typeof b.url === "string") { - images.push({ url: b.url }); + appendImageBlock(images, { url: b.url }); } } else if (b.type === "image_url") { // OpenAI format const imageUrl = b.image_url as Record | undefined; if (typeof imageUrl?.url === "string") { - images.push({ url: imageUrl.url }); + appendImageBlock(images, { url: imageUrl.url }); + } + } else if (b.type === "input_image") { + const source = b.source as Record | undefined; + if (typeof source?.url === "string") { + appendImageBlock(images, { url: source.url }); + } else if (typeof source?.data === "string") { + appendImageBlock(images, { + url: buildBase64ImageUrl({ + data: source.data, + mediaType: typeof source.media_type === "string" ? source.media_type : undefined, + }), + }); } } } } + const transcriptMediaPaths = Array.isArray(m.MediaPaths) + ? m.MediaPaths.filter((value): value is string => typeof value === "string") + : typeof m.MediaPath === "string" + ? [m.MediaPath] + : []; + for (const mediaPath of transcriptMediaPaths) { + appendImageBlock(images, { url: mediaPath }); + } + return images; } @@ -575,7 +609,14 @@ function isAvatarUrl(value: string): boolean { ); } -function renderMessageImages(images: ImageBlock[]) { +function renderMessageImages( + images: ImageBlock[], + opts?: { + localMediaPreviewRoots?: readonly string[]; + basePath?: string; + authToken?: string | null; + }, +) { if (images.length === 0) { return nothing; } @@ -586,16 +627,22 @@ function renderMessageImages(images: ImageBlock[]) { return html`
- ${images.map( - (img) => html` + ${images.map((img) => { + const canProxyLocalImage = + isLocalAssistantAttachmentSource(img.url) && + isLocalAttachmentPreviewAllowed(img.url, opts?.localMediaPreviewRoots ?? []); + const imageUrl = canProxyLocalImage + ? buildAssistantAttachmentUrl(img.url, opts?.basePath, opts?.authToken) + : img.url; + return html` ${img.alt openImage(img.url)} + @click=${() => openImage(imageUrl)} /> - `, - )} + `; + })}
`; } @@ -1163,7 +1210,11 @@ function renderGroupedMessage( ${toolMessageExpanded ? html`
- ${renderMessageImages(images)} + ${renderMessageImages(images, { + localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [], + basePath: opts.basePath, + authToken: opts.assistantAttachmentAuthToken, + })} ${renderAssistantAttachments( assistantAttachments, opts.localMediaPreviewRoots ?? [], @@ -1219,7 +1270,11 @@ function renderGroupedMessage(
` : html` - ${renderMessageImages(images)} + ${renderMessageImages(images, { + localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [], + basePath: opts.basePath, + authToken: opts.assistantAttachmentAuthToken, + })} ${renderAssistantAttachments( assistantAttachments, opts.localMediaPreviewRoots ?? [], diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 9cd466b46d6..78d69bc4896 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -96,6 +96,15 @@ function renderAssistantMessage( container: HTMLElement, message: unknown, opts: Partial = {}, +) { + renderGroupedMessage(container, message, "assistant", opts); +} + +function renderGroupedMessage( + container: HTMLElement, + message: unknown, + role: string, + opts: Partial = {}, ) { const timestamp = typeof message === "object" && @@ -105,9 +114,9 @@ function renderAssistantMessage( : Date.now(); const group: MessageGroup = { kind: "group", - key: "assistant-group", - role: "assistant", - messages: [{ key: "assistant-message", message }], + key: `${role}-group`, + role, + messages: [{ key: `${role}-message`, message }], timestamp, isStreaming: false, }; @@ -910,6 +919,33 @@ describe("chat view", () => { expect(container.textContent).not.toContain("MEDIA:https://example.com/photo.png"); }); + it("keeps user transcript images visible after history reload", () => { + const container = document.createElement("div"); + + renderGroupedMessage( + container, + { + id: "user-history-image", + role: "user", + content: "", + MediaPath: "/tmp/openclaw/user-upload.png", + timestamp: Date.now(), + }, + "user", + { + showToolCalls: false, + basePath: "/openclaw", + assistantAttachmentAuthToken: "session-token", + localMediaPreviewRoots: ["/tmp/openclaw"], + }, + ); + + const image = container.querySelector(".chat-message-image"); + expect(image?.getAttribute("src")).toBe( + "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fuser-upload.png&token=session-token", + ); + }); + it("opens only safe assistant image URLs in a hardened new tab", () => { const container = document.createElement("div"); const openSpy = vi.spyOn(window, "open").mockReturnValue(null);