mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
fix: keep history-backed chat images visible
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
82
src/channels/plugins/setup-promotion-helpers.test.ts
Normal file
82
src/channels/plugins/setup-promotion-helpers.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string, unknown>)[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",
|
||||
|
||||
@@ -26,6 +26,11 @@ export async function withTempHome<T>(fn: (home: string) => Promise<T>): 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 {
|
||||
|
||||
@@ -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<string, unknown>).url;
|
||||
if (typeof url === "string") {
|
||||
appendImageBlock(images, { url });
|
||||
}
|
||||
}
|
||||
const source = b.source as Record<string, unknown> | 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`
|
||||
<div class="chat-message-images">
|
||||
${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
|
||||
src=${imageUrl}
|
||||
src=${img.displayUrl}
|
||||
alt=${img.alt ?? "Attached image"}
|
||||
class="chat-message-image"
|
||||
@click=${() => openImage(imageUrl)}
|
||||
@click=${() => openImage(img.displayUrl)}
|
||||
/>
|
||||
`;
|
||||
})}
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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`
|
||||
<div class="chat-tool-msg-body">
|
||||
${renderMessageImages(images, {
|
||||
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
|
||||
basePath: opts.basePath,
|
||||
authToken: opts.assistantAttachmentAuthToken,
|
||||
})}
|
||||
${renderMessageImages(images)}
|
||||
${renderAssistantAttachments(
|
||||
assistantAttachments,
|
||||
opts.localMediaPreviewRoots ?? [],
|
||||
@@ -1316,11 +1337,7 @@ function renderGroupedMessage(
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${renderMessageImages(images, {
|
||||
localMediaPreviewRoots: opts.localMediaPreviewRoots ?? [],
|
||||
basePath: opts.basePath,
|
||||
authToken: opts.assistantAttachmentAuthToken,
|
||||
})}
|
||||
${renderMessageImages(images)}
|
||||
${renderAssistantAttachments(
|
||||
assistantAttachments,
|
||||
opts.localMediaPreviewRoots ?? [],
|
||||
|
||||
@@ -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<HTMLImageElement>(".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<HTMLImageElement>(".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);
|
||||
|
||||
Reference in New Issue
Block a user