From 6d40de45c7b7fbd9ae4a725b1072d9de7abe4c74 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 20:27:38 +0100 Subject: [PATCH] fix: keep history-backed chat images visible --- CHANGELOG.md | 1 + .../plugins/setup-promotion-helpers.test.ts | 82 ++++++++++++++++++ .../plugins/setup-promotion-helpers.ts | 26 ++++-- src/config/defaults.test.ts | 26 +++++- src/config/defaults.ts | 29 +++++++ src/config/test-helpers.ts | 5 ++ ui/src/ui/chat/grouped-render.ts | 85 +++++++++++-------- ui/src/ui/views/chat.test.ts | 49 +++++++++++ 8 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 src/channels/plugins/setup-promotion-helpers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f656df693eb..2fcd2ad42d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Slack/threads: keep file-only root messages as starter context so first thread replies can still hydrate starter media. (#68594) Thanks @martingarramon. - Google/Antigravity: resolve forward-compatible Gemini 3.1 Pro custom-tools and Flash variants from the bundled Google plugin templates, so `google-antigravity/gemini-3.1-pro-preview-customtools` no longer falls through to an unknown-model error. Fixes #35512. - Active Memory: raise the blocking recall timeout ceiling to 120 seconds and reject larger config values during plugin schema validation. Fixes #68410. (#68480) Thanks @Bartok9. +- Control UI/chat: keep history-backed user image uploads visible after chat reload while filtering blocked or non-image transcript media paths. (#68415) Thanks @mraleko. ## 2026.4.15 diff --git a/src/channels/plugins/setup-promotion-helpers.test.ts b/src/channels/plugins/setup-promotion-helpers.test.ts new file mode 100644 index 00000000000..6d70b0e71fc --- /dev/null +++ b/src/channels/plugins/setup-promotion-helpers.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getBundledChannelPluginMock = vi.hoisted(() => vi.fn()); +const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn()); + +vi.mock("./bundled.js", () => ({ + getBundledChannelPlugin: getBundledChannelPluginMock, +})); + +vi.mock("./registry.js", () => ({ + getLoadedChannelPlugin: getLoadedChannelPluginMock, +})); + +import { + resolveSingleAccountKeysToMove, + shouldMoveSingleAccountChannelKey, +} from "./setup-promotion-helpers.js"; + +describe("setup promotion helpers", () => { + beforeEach(() => { + getBundledChannelPluginMock.mockReset(); + getLoadedChannelPluginMock.mockReset(); + }); + + it("keeps static named-account migration keys cheap", () => { + const keys = resolveSingleAccountKeysToMove({ + channelKey: "whatsapp", + channel: { + accounts: { + work: { enabled: true }, + }, + dmPolicy: "allowlist", + allowFrom: ["+15551234567"], + groupPolicy: "allowlist", + groupAllowFrom: ["120363000000000000@g.us"], + }, + }); + + expect(keys).toEqual(["dmPolicy", "allowFrom", "groupPolicy", "groupAllowFrom"]); + expect(getLoadedChannelPluginMock).toHaveBeenCalledTimes(1); + expect(getLoadedChannelPluginMock).toHaveBeenCalledWith("whatsapp"); + expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); + }); + + it("loads bundled setup only for non-static migration keys", () => { + getBundledChannelPluginMock.mockReturnValue({ + setup: { + singleAccountKeysToMove: ["customAuth"], + }, + }); + + expect( + shouldMoveSingleAccountChannelKey({ + channelKey: "demo", + key: "customAuth", + }), + ).toBe(true); + expect(getBundledChannelPluginMock).toHaveBeenCalledWith("demo"); + }); + + it("honors loaded plugin named-account filters without bundled fallback", () => { + getLoadedChannelPluginMock.mockReturnValue({ + setup: { + namedAccountPromotionKeys: ["token"], + }, + }); + + const keys = resolveSingleAccountKeysToMove({ + channelKey: "demo", + channel: { + accounts: { + work: { enabled: true }, + }, + token: "secret", + dmPolicy: "allowlist", + }, + }); + + expect(keys).toEqual(["token"]); + expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/plugins/setup-promotion-helpers.ts b/src/channels/plugins/setup-promotion-helpers.ts index 3a9355fff93..5caebe595ec 100644 --- a/src/channels/plugins/setup-promotion-helpers.ts +++ b/src/channels/plugins/setup-promotion-helpers.ts @@ -1,7 +1,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { getBundledChannelPlugin } from "./bundled.js"; -import { getChannelPlugin } from "./registry.js"; +import { getLoadedChannelPlugin } from "./registry.js"; type ChannelSectionBase = { defaultAccount?: string; @@ -59,8 +59,13 @@ type ChannelSetupPromotionSurface = { }) => string | undefined; }; -function getChannelSetupPromotionSurface(channelKey: string): ChannelSetupPromotionSurface | null { - const setup = getChannelPlugin(channelKey)?.setup ?? getBundledChannelPlugin(channelKey)?.setup; +function getChannelSetupPromotionSurface( + channelKey: string, + opts?: { loadBundledFallback?: boolean }, +): ChannelSetupPromotionSurface | null { + const setup = + getLoadedChannelPlugin(channelKey)?.setup ?? + (opts?.loadBundledFallback ? getBundledChannelPlugin(channelKey)?.setup : undefined); if (!setup || typeof setup !== "object") { return null; } @@ -81,7 +86,9 @@ export function shouldMoveSingleAccountChannelKey(params: { if (isStaticSingleAccountPromotionKey(params.channelKey, params.key)) { return true; } - const contractKeys = getChannelSetupPromotionSurface(params.channelKey)?.singleAccountKeysToMove; + const contractKeys = getChannelSetupPromotionSurface(params.channelKey, { + loadBundledFallback: true, + })?.singleAccountKeysToMove; if (contractKeys?.includes(params.key)) { return true; } @@ -104,7 +111,9 @@ export function resolveSingleAccountKeysToMove(params: { let setupSurface: ChannelSetupPromotionSurface | null | undefined; const resolveSetupSurface = () => { - setupSurface ??= getChannelSetupPromotionSurface(params.channelKey); + setupSurface ??= getChannelSetupPromotionSurface(params.channelKey, { + loadBundledFallback: true, + }); return setupSurface; }; @@ -119,7 +128,8 @@ export function resolveSingleAccountKeysToMove(params: { } const namedAccountPromotionKeys = - resolveSetupSurface()?.namedAccountPromotionKeys ?? + setupSurface?.namedAccountPromotionKeys ?? + getChannelSetupPromotionSurface(params.channelKey)?.namedAccountPromotionKeys ?? BUNDLED_NAMED_ACCOUNT_PROMOTION_FALLBACKS[params.channelKey]; if (!namedAccountPromotionKeys) { return keysToMove; @@ -139,7 +149,9 @@ export function resolveSingleAccountPromotionTarget(params: { ); return matchedAccountId ?? normalizedTargetAccountId; }; - const surface = getChannelSetupPromotionSurface(params.channelKey); + const surface = getChannelSetupPromotionSurface(params.channelKey, { + loadBundledFallback: true, + }); const resolved = surface?.resolveSingleAccountPromotionTarget?.({ channel: params.channel, }); diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 8a993801a88..83587407cd7 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; import { applyAgentDefaults, @@ -21,6 +21,12 @@ vi.mock("./provider-policy.js", () => ({ describe("config defaults", () => { beforeEach(() => { mocks.applyProviderConfigDefaultsForConfig.mockReset(); + vi.stubEnv("ANTHROPIC_API_KEY", ""); + vi.stubEnv("ANTHROPIC_OAUTH_TOKEN", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it("skips provider defaults when agent defaults are absent", () => { @@ -38,12 +44,28 @@ describe("config defaults", () => { expect(mocks.applyProviderConfigDefaultsForConfig).not.toHaveBeenCalled(); }); - it("uses anthropic provider defaults when agent defaults exist", () => { + it("skips provider defaults when agent defaults have no Anthropic auth signal", () => { const cfg = { agents: { defaults: {}, }, }; + + expect(applyContextPruningDefaults(cfg as never)).toBe(cfg); + expect(mocks.applyProviderConfigDefaultsForConfig).not.toHaveBeenCalled(); + }); + + it("uses anthropic provider defaults when agent defaults and auth signal exist", () => { + const cfg = { + auth: { + profiles: { + anthropic: { provider: "anthropic", mode: "api_key" }, + }, + }, + agents: { + defaults: {}, + }, + }; const nextCfg = { agents: { defaults: { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 7d7936e37b7..7dbda01e857 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -338,10 +338,39 @@ export function applyLoggingDefaults(cfg: OpenClawConfig): OpenClawConfig { }; } +function hasAnthropicDefaultSignal(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + if (env.ANTHROPIC_API_KEY?.trim() || env.ANTHROPIC_OAUTH_TOKEN?.trim()) { + return true; + } + const profiles = cfg.auth?.profiles; + if (profiles) { + for (const profile of Object.values(profiles)) { + const provider = normalizeProviderId(profile?.provider); + if (provider === "anthropic" || provider === "claude-cli") { + return true; + } + } + } + const order = cfg.auth?.order; + if (!order) { + return false; + } + return Object.keys(order).some((provider) => { + const normalizedProvider = normalizeProviderId(provider); + if (normalizedProvider !== "anthropic" && normalizedProvider !== "claude-cli") { + return false; + } + return (order as Record)[provider] !== undefined; + }); +} + export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig { if (!cfg.agents?.defaults) { return cfg; } + if (!hasAnthropicDefaultSignal(cfg, process.env)) { + return cfg; + } return ( applyProviderConfigDefaultsForConfig({ provider: "anthropic", diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index 09045874b2d..923aa5454aa 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -26,6 +26,11 @@ export async function withTempHome(fn: (home: string) => Promise): Promise OPENCLAW_MPM_CATALOG_PATHS: undefined, OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: undefined, OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: undefined, + OPENCLAW_LOAD_SHELL_ENV: undefined, + OPENCLAW_DEFER_SHELL_ENV_FALLBACK: undefined, + OPENCLAW_SHELL_ENV_TIMEOUT_MS: undefined, + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_OAUTH_TOKEN: undefined, }, }); } finally { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index adc5df0da58..aec7c5cc59c 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -52,6 +52,16 @@ type ImageBlock = { alt?: string; }; +type ImageRenderOptions = { + localMediaPreviewRoots?: readonly string[]; + basePath?: string; + authToken?: string | null; +}; + +type RenderableImageBlock = ImageBlock & { + displayUrl: string; +}; + function appendImageBlock(images: ImageBlock[], block: ImageBlock) { if (!images.some((entry) => entry.url === block.url && entry.alt === block.alt)) { images.push(block); @@ -130,6 +140,15 @@ function extractImages(message: unknown): ImageBlock[] { appendImageBlock(images, { url: imageUrl.url }); } } else if (b.type === "input_image") { + const imageUrl = b.image_url; + if (typeof imageUrl === "string") { + appendImageBlock(images, { url: imageUrl }); + } else if (imageUrl && typeof imageUrl === "object") { + const url = (imageUrl as Record).url; + if (typeof url === "string") { + appendImageBlock(images, { url }); + } + } const source = b.source as Record | undefined; if (typeof source?.url === "string") { appendImageBlock(images, { url: source.url }); @@ -651,14 +670,25 @@ function isAvatarUrl(value: string): boolean { ); } -function renderMessageImages( +function resolveRenderableMessageImages( images: ImageBlock[], - opts?: { - localMediaPreviewRoots?: readonly string[]; - basePath?: string; - authToken?: string | null; - }, -) { + opts?: ImageRenderOptions, +): RenderableImageBlock[] { + return images.flatMap((img) => { + const isLocalImage = isLocalAssistantAttachmentSource(img.url); + const canProxyLocalImage = + isLocalImage && isLocalAttachmentPreviewAllowed(img.url, opts?.localMediaPreviewRoots ?? []); + if (isLocalImage && !canProxyLocalImage) { + return []; + } + const displayUrl = canProxyLocalImage + ? buildAssistantAttachmentUrl(img.url, opts?.basePath, opts?.authToken) + : img.url; + return [{ ...img, displayUrl }]; + }); +} + +function renderMessageImages(images: RenderableImageBlock[]) { if (images.length === 0) { return nothing; } @@ -669,26 +699,16 @@ function renderMessageImages( return html`
- ${images.map((img) => { - const isLocalImage = isLocalAssistantAttachmentSource(img.url); - const canProxyLocalImage = - isLocalImage && - isLocalAttachmentPreviewAllowed(img.url, opts?.localMediaPreviewRoots ?? []); - if (isLocalImage && !canProxyLocalImage) { - return nothing; - } - const imageUrl = canProxyLocalImage - ? buildAssistantAttachmentUrl(img.url, opts?.basePath, opts?.authToken) - : img.url; - return html` + ${images.map( + (img) => html` ${img.alt openImage(imageUrl)} + @click=${() => openImage(img.displayUrl)} /> - `; - })} + `, + )}
`; } @@ -1155,7 +1175,12 @@ function renderGroupedMessage( const toolCards = (opts.showToolCalls ?? true) ? extractToolCards(message, messageKey) : []; const hasToolCards = toolCards.length > 0; - const images = extractImages(message); + const imageRenderOptions = { + localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [], + basePath: opts.basePath, + authToken: opts.assistantAttachmentAuthToken, + }; + const images = resolveRenderableMessageImages(extractImages(message), imageRenderOptions); const hasImages = images.length > 0; const normalizedMessage = normalizeMessage(message); @@ -1256,11 +1281,7 @@ function renderGroupedMessage( ${toolMessageExpanded ? html`
- ${renderMessageImages(images, { - localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [], - basePath: opts.basePath, - authToken: opts.assistantAttachmentAuthToken, - })} + ${renderMessageImages(images)} ${renderAssistantAttachments( assistantAttachments, opts.localMediaPreviewRoots ?? [], @@ -1316,11 +1337,7 @@ function renderGroupedMessage(
` : html` - ${renderMessageImages(images, { - localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [], - basePath: opts.basePath, - authToken: opts.assistantAttachmentAuthToken, - })} + ${renderMessageImages(images)} ${renderAssistantAttachments( assistantAttachments, opts.localMediaPreviewRoots ?? [], diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 5c555697762..44819b83d08 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -974,6 +974,37 @@ describe("chat view", () => { ); }); + it("keeps plural user transcript images visible after history reload", () => { + const container = document.createElement("div"); + + renderGroupedMessage( + container, + { + id: "user-history-images", + role: "user", + content: "", + MediaPaths: ["/tmp/openclaw/first.png", "/tmp/openclaw/second.jpg"], + MediaTypes: ["image/png", "application/octet-stream"], + timestamp: Date.now(), + }, + "user", + { + showToolCalls: false, + basePath: "/openclaw", + assistantAttachmentAuthToken: "session-token", + localMediaPreviewRoots: ["/tmp/openclaw"], + }, + ); + + const imageSources = [ + ...container.querySelectorAll(".chat-message-image"), + ].map((image) => image.getAttribute("src")); + expect(imageSources).toEqual([ + "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ffirst.png&token=session-token", + "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fsecond.jpg&token=session-token", + ]); + }); + it("does not render blocked local transcript image paths", () => { const container = document.createElement("div"); @@ -997,6 +1028,7 @@ describe("chat view", () => { ); expect(container.querySelector(".chat-message-image")).toBeNull(); + expect(container.querySelector(".chat-bubble")).toBeNull(); }); it("skips non-image transcript media paths after history reload", () => { @@ -1024,6 +1056,23 @@ describe("chat view", () => { expect(container.querySelector(".chat-message-image")).toBeNull(); }); + it("renders legacy input_image image_url blocks", () => { + const container = document.createElement("div"); + + renderAssistantMessage( + container, + { + role: "assistant", + content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }], + timestamp: Date.now(), + }, + { showToolCalls: false }, + ); + + const image = container.querySelector(".chat-message-image"); + expect(image?.getAttribute("src")).toBe("data:image/png;base64,cG5n"); + }); + 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);