mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): show a placeholder for remote markdown images (#122312)
* fix(ui): show remote image placeholders in chat Keep remote Markdown images fetch-blocked while giving users a visible outcome and an explicit HTTPS open action. * fix(ui): preserve links around image placeholders
This commit is contained in:
committed by
GitHub
parent
89bae11799
commit
7c83fc1729
@@ -84,6 +84,11 @@ export function installAssistantTranscriptRoleImageRenderer(
|
||||
normalizeLabel: (value: string) => string;
|
||||
assistantLabel: () => string;
|
||||
openImageLabel: (alt: string, hasAlt: boolean) => string;
|
||||
renderExternalImageFallback: (
|
||||
src: string,
|
||||
renderedLabel: string,
|
||||
linkedImage: boolean,
|
||||
) => string;
|
||||
interactiveImages: (env: unknown) => boolean;
|
||||
allowRemoteImages: (env: unknown) => boolean;
|
||||
},
|
||||
@@ -98,13 +103,14 @@ export function installAssistantTranscriptRoleImageRenderer(
|
||||
const alt = options.normalizeLabel(token.content);
|
||||
const roleMeta = (token.meta as AssistantTranscriptRoleImageMeta | undefined)
|
||||
?.assistantTranscriptRoleImage;
|
||||
const linkedImage = isImageWithinLink(tokens, index);
|
||||
if (!options.isInlineDataImage(src) && !options.allowRemoteImages(env)) {
|
||||
return roleMeta
|
||||
const renderedLabel = roleMeta
|
||||
? renderAssistantTranscriptRoleImageLabel(roleMeta.text, roleMeta.spans, options.escapeHtml)
|
||||
: options.escapeHtml(alt);
|
||||
return options.renderExternalImageFallback(src, renderedLabel, linkedImage);
|
||||
}
|
||||
const image = `<img class="markdown-inline-image" src="${options.escapeHtml(src)}" alt="${options.escapeHtml(alt)}">`;
|
||||
const linkedImage = isImageWithinLink(tokens, index);
|
||||
const interactiveImage =
|
||||
linkedImage || !options.interactiveImages(env)
|
||||
? image
|
||||
|
||||
@@ -571,8 +571,8 @@ export function createMarkdownParser(): MarkdownIt {
|
||||
return `<a class="markdown-file-link" role="button" tabindex="0" data-file-path="${escapeMarkdownHtml(target.path)}" data-file-kind="${fileKindForPath(target.path)}"${lineAttribute}${titleAttribute}>${rendered}</a>`;
|
||||
};
|
||||
|
||||
// Message rendering allows only inline data images (#15437). Document
|
||||
// previews preserve authored image URLs and rely on DOMPurify's URI policy.
|
||||
// Message rendering allows inline data images and explicit open-only placeholders
|
||||
// for remote URLs. Document previews preserve authored URLs for direct rendering.
|
||||
installAssistantTranscriptRoleImageRenderer(markdownParser, {
|
||||
escapeHtml: escapeMarkdownHtml,
|
||||
isInlineDataImage: (src) => INLINE_DATA_IMAGE_RE.test(src),
|
||||
@@ -582,6 +582,16 @@ export function createMarkdownParser(): MarkdownIt {
|
||||
t("chat.imageLightbox.open", {
|
||||
title: hasAlt ? alt : t("chat.imageLightbox.untitled"),
|
||||
}),
|
||||
renderExternalImageFallback: (src, renderedLabel, linkedImage) => {
|
||||
if (!parseWebLinkHref(src)) {
|
||||
return renderedLabel;
|
||||
}
|
||||
const label = `<span>${escapeMarkdownHtml(t("chat.externalImage.notLoaded"))}: ${renderedLabel}</span>`;
|
||||
const action = linkedImage
|
||||
? ""
|
||||
: ` <a href="${escapeMarkdownHtml(src)}">${escapeMarkdownHtml(t("chat.externalImage.open"))}</a>`;
|
||||
return `<span class="markdown-external-image">${label}${action}</span>`;
|
||||
},
|
||||
interactiveImages: (env) =>
|
||||
(env as Partial<MarkdownRenderEnv> | undefined)?.interactiveImages === true,
|
||||
allowRemoteImages: (env) =>
|
||||
|
||||
@@ -139,30 +139,52 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
});
|
||||
|
||||
describe("images", () => {
|
||||
it("flattens remote images to alt text", () => {
|
||||
const html = toSanitizedMarkdownHtml("");
|
||||
expect(html).toBe("<p>Alt text</p>\n");
|
||||
it("shows an explicit opt-in placeholder for remote images", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(""),
|
||||
);
|
||||
const placeholder = fragment.querySelector(".markdown-external-image");
|
||||
const link = placeholder?.querySelector("a");
|
||||
|
||||
expect(placeholder?.textContent).toBe("External image not loaded: Alt text Open image");
|
||||
expect(link?.getAttribute("href")).toBe("https://example.com/img.png");
|
||||
expect(link?.getAttribute("target")).toBe("_blank");
|
||||
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
|
||||
expect(fragment.querySelector("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks assistant-authored transcript roles in visible image labels", () => {
|
||||
const html = toSanitizedMarkdownHtml(
|
||||
"![**user**[Thu 2026-07-02] release diagram](https://example.com/img.png)",
|
||||
{ assistantTranscriptRoleHeaders: true },
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(
|
||||
"![**user**[Thu 2026-07-02] release diagram](https://example.com/img.png)",
|
||||
{ assistantTranscriptRoleHeaders: true },
|
||||
),
|
||||
);
|
||||
|
||||
expect(html).toBe(
|
||||
'<p><code class="assistant-transcript-role">user[Thu 2026-07-02]</code> release diagram</p>\n',
|
||||
expect(
|
||||
fragment.querySelector(".markdown-external-image .assistant-transcript-role")?.textContent,
|
||||
).toBe("user[Thu 2026-07-02]");
|
||||
expect(fragment.querySelector(".markdown-external-image")?.textContent).toContain(
|
||||
"release diagram",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves markdown formatting in alt text", () => {
|
||||
const html = toSanitizedMarkdownHtml("");
|
||||
expect(html).toBe("<p>**Build log**</p>\n");
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(""),
|
||||
);
|
||||
expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toContain(
|
||||
"**Build log**",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves code formatting in alt text", () => {
|
||||
const html = toSanitizedMarkdownHtml("");
|
||||
expect(html).toBe("<p>`error.log`</p>\n");
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(""),
|
||||
);
|
||||
expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toContain(
|
||||
"`error.log`",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves base64 data URI images (#15437)", () => {
|
||||
@@ -196,6 +218,22 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
expect(fragment.querySelector("a button")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves rich authored links around remote image placeholders", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(
|
||||
"[Before  after](https://example.com/full.png)",
|
||||
),
|
||||
);
|
||||
const links = fragment.querySelectorAll("a");
|
||||
const placeholder = links[0]?.querySelector(".markdown-external-image");
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]?.getAttribute("href")).toBe("https://example.com/full.png");
|
||||
expect(placeholder?.textContent).toBe("External image not loaded: Preview");
|
||||
expect(placeholder?.querySelector("a")).toBeNull();
|
||||
expect(fragment.querySelector("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("tracks linked and standalone images across one inline token stream", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(
|
||||
@@ -234,8 +272,10 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
});
|
||||
|
||||
it("uses fallback label for unlabeled images", () => {
|
||||
const html = toSanitizedMarkdownHtml("");
|
||||
expect(html).toBe("<p>image</p>\n");
|
||||
const fragment = htmlFragment(toSanitizedMarkdownHtml(""));
|
||||
expect(fragment.querySelector(".markdown-external-image > span")?.textContent).toBe(
|
||||
"External image not loaded: image",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4934,6 +4934,10 @@ export const en: TranslationMap = {
|
||||
close: "Close image preview",
|
||||
untitled: "Image",
|
||||
},
|
||||
externalImage: {
|
||||
notLoaded: "External image not loaded",
|
||||
open: "Open image",
|
||||
},
|
||||
messages: {
|
||||
activity: "Activity",
|
||||
copySelection: "Copy",
|
||||
|
||||
@@ -313,6 +313,21 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:is(.chat-text, .sidebar-markdown) .markdown-external-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35em 0.75em;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 0.75em;
|
||||
padding: 0.5em 0.65em;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-muted);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Code surfaces must lift off the host bubble in every theme. --secondary equals
|
||||
--card on every dark palette, so chips painted with it vanished inside user
|
||||
bubbles; --bg-muted/--border-strong separate in both modes without an override. */
|
||||
|
||||
Reference in New Issue
Block a user