mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(ui): keep history-backed user image messages visible
This commit is contained in:
committed by
Peter Steinberger
parent
cfd796a515
commit
b5038fd9a1
@@ -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 ?? [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user