历史图片回退保持归档入口和正确媒体类型 #37

Merged
WuYongJie merged 1 commits from codex/legacy-image-fallback-20261105 into main 2026-11-05 09:57:48 +08:00
7 changed files with 67 additions and 39 deletions

View File

@ -81,7 +81,7 @@ import { canvasService } from "@/services/canvas";
import {
inferOriginalImageUrl,
} from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
@ -15812,8 +15812,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
}
}}
/>
@ -16131,8 +16131,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
}
}}
/>
@ -16208,8 +16208,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
}
}}
/>
@ -16394,8 +16394,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== tag.iconUrl) {
event.currentTarget.src = tag.iconUrl || "";
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
}
}}
/>
@ -16695,8 +16695,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = attachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
}
}}
/>
@ -17032,8 +17032,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== url) {
event.currentTarget.src = url;
if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
event.currentTarget.src = buildImageFallbackUrl(url);
}
}}
onClick={() => setFullscreenPreview({ url })}
@ -17885,8 +17885,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
}
}}
/>
@ -18210,8 +18210,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
}
}}
/>
@ -18470,8 +18470,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== item.url) {
event.currentTarget.src = item.url;
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
event.currentTarget.src = buildImageFallbackUrl(item.url);
}
}}
/>
@ -18655,8 +18655,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
}
}}
/>
@ -18723,8 +18723,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
}
}}
/>
@ -19423,8 +19423,8 @@ export function CanvasChatPanel({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== tagPreview.url) {
event.currentTarget.src = tagPreview.url;
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
}
}}
/>

View File

@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
}
}
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "")
);

View File

@ -186,7 +186,7 @@ import {
normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
@ -8466,8 +8466,8 @@ export function ImageNode({
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = buildImageFallbackUrl(data.src);
}
}}
onLoad={handleDisplayImageLoad}
@ -8495,8 +8495,8 @@ export function ImageNode({
draggable={false}
loading="lazy"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
event.currentTarget.src = buildImageFallbackUrl(compareSource);
}
}}
src={displayCompareSrc}
@ -8563,8 +8563,8 @@ export function ImageNode({
}
}}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = buildImageFallbackUrl(data.src);
}
}}
onLoad={handleDisplayImageLoad}

View File

@ -15,7 +15,7 @@ import {
splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
@ -442,8 +442,8 @@ function MarkdownTableImagePreview({
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== src) {
event.currentTarget.src = src;
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
event.currentTarget.src = buildImageFallbackUrl(src);
}
}}
/>
@ -473,8 +473,8 @@ function MarkdownTableImagePreview({
decoding="async"
onClick={(event) => event.stopPropagation()}
onError={(event) => {
if (event.currentTarget.src !== originalSrc) {
event.currentTarget.src = originalSrc;
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
}
}}
/>

View File

@ -2,6 +2,15 @@ import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy";
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string {
const source = value || "";
const legacy = normalizeLegacyAliyunStorageUrl(source);
if (!legacy) return source;
const proxy = buildImageProxyUrl(legacy);
return typeof window === "undefined" ? proxy : new URL(proxy, window.location.origin).toString();
}
type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png";
export type ImageProxyOptions = {

View File

@ -1,4 +1,15 @@
import { expect, test } from "@playwright/test";
test("image metadata containing video or audio paths remains an image", () => {
for (const folder of ["video-canvas-demo", "audio-preview"]) {
const url = `https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/${folder}/cover.png`;
const markdown = `![旧链接图片|||${url}|||2K](${url})`;
expect(extractVideoUrls(markdown)).toEqual([]);
expect(extractAudioUrls(markdown)).toEqual([]);
expect(extractAssistantImageUrls(markdown)).toContain(url);
expect(stripMediaMarkup(markdown)).toBe(markdown);
}
});
import {
collectMessageImageUrls,
extractAssistantImageUrls,

View File

@ -41,6 +41,14 @@ test("legacy display URLs go through archive resolution while other Alibaba buck
expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toContain("x-oss-process=");
});
test("browser image fallbacks remain on one stable archive URL instead of retrying the old source", () => {
const delivery = serverLoader({ window: { location: { origin: "https://app.example" } } })("lib/image-delivery.ts");
const fallback = delivery.buildImageFallbackUrl(legacy);
expect(fallback).toMatch(/^https:\/\/app\.example\/api\/image-proxy\?/);
expect(delivery.buildImageFallbackUrl(fallback)).toBe(fallback);
expect(delivery.buildImageFallbackUrl(permanent)).toBe(permanent);
});
function archiveLoader(rows: Array<{ sourceUrl: string; targetUrl: string | null }>) {
return serverLoader({
"@/lib/db": { db: { select: () => ({ from: () => ({ where: async () => rows }) }), insert: () => { throw new Error("Legacy sources must not be queued"); } } },