fix(ui): keep history-backed user image messages visible

This commit is contained in:
Alec Hrdina
2026-04-18 00:15:12 -05:00
committed by Peter Steinberger
parent cfd796a515
commit b5038fd9a1
2 changed files with 110 additions and 19 deletions

View File

@@ -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<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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`
<div class="chat-message-images">
${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
src=${img.url}
src=${imageUrl}
alt=${img.alt ?? "Attached image"}
class="chat-message-image"
@click=${() => openImage(img.url)}
@click=${() => openImage(imageUrl)}
/>
`,
)}
`;
})}
</div>
`;
}
@@ -1163,7 +1210,11 @@ function renderGroupedMessage(
${toolMessageExpanded
? html`
<div class="chat-tool-msg-body">
${renderMessageImages(images)}
${renderMessageImages(images, {
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
basePath: opts.basePath,
authToken: opts.assistantAttachmentAuthToken,
})}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],
@@ -1219,7 +1270,11 @@ function renderGroupedMessage(
</div>
`
: html`
${renderMessageImages(images)}
${renderMessageImages(images, {
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
basePath: opts.basePath,
authToken: opts.assistantAttachmentAuthToken,
})}
${renderAssistantAttachments(
assistantAttachments,
opts.localMediaPreviewRoots ?? [],

View File

@@ -96,6 +96,15 @@ function renderAssistantMessage(
container: HTMLElement,
message: unknown,
opts: Partial<RenderMessageGroupOptions> = {},
) {
renderGroupedMessage(container, message, "assistant", opts);
}
function renderGroupedMessage(
container: HTMLElement,
message: unknown,
role: string,
opts: Partial<RenderMessageGroupOptions> = {},
) {
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<HTMLImageElement>(".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);