diff --git a/components/elements/response.tsx b/components/elements/response.tsx index c5f55bf87..5ce92c208 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -6,7 +6,7 @@ import type { PointerEvent as ReactPointerEvent, ReactNode, } from "react"; -import { useRef, useState } from "react"; +import { createContext, useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { ImagePreview } from "@/components/image-preview"; import { @@ -15,6 +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 { Collapsible, CollapsibleContent, @@ -78,8 +79,11 @@ type MarkdownTableHeaderCellProps = ComponentProps<"th"> & { node?: unknown; }; +const MarkdownTableContext = createContext(false); const TABLE_DRAG_INTERACTIVE_SELECTOR = "a, button, input, textarea, select, summary, [role='button'], [contenteditable='true'], [data-no-drag='true']"; +const IMAGE_URL_REGEX = /^https?:\/\/\S+\.(?:png|jpe?g|webp|gif|avif)(?:[?#]\S*)?$/i; +const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/i; function isTableDragInteractiveTarget(target: EventTarget | null) { return ( @@ -393,6 +397,124 @@ function splitMarkdownImages(content: string) { return segments; } +function isLikelyImageUrl(value?: string | null) { + return IMAGE_URL_REGEX.test(String(value || "").trim()); +} + +function getImageUrlFromTableText(value: ReactNode) { + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + const markdownImageMatch = trimmed.match(MARKDOWN_IMAGE_REGEX); + if (markdownImageMatch?.[2] && isLikelyImageUrl(markdownImageMatch[2])) { + return markdownImageMatch[2]; + } + + return isLikelyImageUrl(trimmed) ? trimmed : null; +} + +function getOriginalImageUrlFromMarkdownImage(src: string, alt?: string) { + const normalizedAlt = String(alt || ""); + const parseByMarker = (marker: string) => { + if (!normalizedAlt.includes(marker)) return ""; + const content = normalizedAlt.slice(normalizedAlt.indexOf(marker) + marker.length); + return content.split(marker === "thumb|||" ? "|||" : marker === "thumb$$" ? "$$" : "|")[0] || ""; + }; + + return ( + parseByMarker("thumb|||") || + parseByMarker("thumb$$") || + parseByMarker("thumb|") || + src + ); +} + +function MarkdownTableImagePreview({ + alt, + src, +}: { + alt?: string; + src: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const originalSrc = getOriginalImageUrlFromMarkdownImage(src, alt); + const thumbnailSrc = buildImageProxyUrl(src, { + delivery: "proxy", + format: "webp", + quality: 82, + width: 180, + }); + const largeSrc = buildImageProxyUrl(originalSrc, { + delivery: "proxy", + format: "webp", + quality: 90, + width: 1600, + }); + + return ( + <> + + + {isOpen ? ( +
setIsOpen(false)} + > + + {alt event.stopPropagation()} + onError={(event) => { + if (event.currentTarget.src !== originalSrc) { + event.currentTarget.src = originalSrc; + } + }} + /> +
+ ) : null} + + ); +} + function normalizeSectionTitle(line: string) { let normalized = line.trim(); if (!normalized) return ""; @@ -568,29 +690,34 @@ function DownloadableMarkdownTable({ - - {children} -
+ + + {children} +
+
); } function MarkdownTableCell({ + children, className, node: _node, ...props }: MarkdownTableCellProps) { + const imageUrl = getImageUrlFromTableText(children); + return ( + > + {imageUrl ? : children} + ); } @@ -701,23 +830,31 @@ export const Response = ({ } return ( - + + {(isInsideTable) => + isInsideTable ? ( + + ) : ( + + ) + } + ); }, video: ({ src, alt }: { src?: string; alt?: string }) => { @@ -736,22 +873,33 @@ export const Response = ({ onClick, ...linkProps }: ComponentProps<"a">) => ( - { - event.stopPropagation(); - onClick?.(event); - }} - rel="noreferrer" - target="_blank" - > - {children} - + + {(isInsideTable) => + isInsideTable && isLikelyImageUrl(href) ? ( + + ) : ( + { + event.stopPropagation(); + onClick?.(event); + }} + rel="noreferrer" + target="_blank" + > + {children} + + ) + } + ), }; @@ -778,7 +926,11 @@ export const Response = ({ const renderTextSegment = (text: string, keyPrefix: string): ReactNode => { if (!text.trim()) return null; - if (text.includes("![") && text.includes("](")) { + if ( + !MARKDOWN_TABLE_REGEX.test(text) && + text.includes("![") && + text.includes("](") + ) { const segments = splitMarkdownImages(text); const hasImageSegments = segments.some((segment) => segment.type === "image");