1128 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { ChevronDownIcon, DownloadIcon, FileSpreadsheetIcon } from "lucide-react";
import type {
ComponentProps,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import { createContext, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { ImagePreview } from "@/components/image-preview";
import {
chartDataToCsv,
OpenClawChartCard,
splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { VideoPreview } from "@/components/video-preview";
import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown> & {
collapsibleImageAnalysis?: boolean;
enablePdfDownload?: boolean;
pdfFileName?: string;
pdfSiteLogo?: string | null;
pdfSiteName?: string | null;
pdfTitle?: string;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
};
const IMAGE_ANALYSIS_TITLE = "图片分析";
const AGENT_SECTION_TITLES = new Set([
"意图分析",
"任务规划",
"需求理解",
IMAGE_ANALYSIS_TITLE,
"执行计划",
"优化后提示词",
"输出设置",
]);
const MARKDOWN_HEADING_CLASS_NAME =
"[&_h1]:!mb-2.5 [&_h1]:!mt-4 [&_h1]:!text-[22px] [&_h1]:!font-semibold [&_h1]:!leading-[1.3] [&_h1]:!tracking-normal " +
"[&_h2]:!mb-2 [&_h2]:!mt-3.5 [&_h2]:!text-[18px] [&_h2]:!font-semibold [&_h2]:!leading-[1.35] [&_h2]:!tracking-normal " +
"[&_h3]:!mb-1.5 [&_h3]:!mt-3 [&_h3]:!text-[16px] [&_h3]:!font-semibold [&_h3]:!leading-[1.4] [&_h3]:!tracking-normal " +
"[&_h4]:!mb-1.5 [&_h4]:!mt-2.5 [&_h4]:!text-[14px] [&_h4]:!font-semibold [&_h4]:!leading-[1.45] [&_h4]:!tracking-normal " +
"[&_h5]:!text-[13px] [&_h5]:!font-semibold [&_h5]:!leading-[1.45] [&_h5]:!tracking-normal " +
"[&_h6]:!text-[12px] [&_h6]:!font-semibold [&_h6]:!leading-[1.45] [&_h6]:!tracking-normal";
type AnalysisSection = {
before: string;
analysis: string;
after: string;
};
type DownloadableMarkdownTableProps = ComponentProps<"table"> & {
node?: unknown;
};
type MarkdownTableCellProps = ComponentProps<"td"> & {
node?: unknown;
};
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 (
target instanceof HTMLElement &&
Boolean(target.closest(TABLE_DRAG_INTERACTIVE_SELECTOR))
);
}
function HorizontalDragScrollArea({
children,
className,
...props
}: ComponentProps<"div">) {
const scrollRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef<{
pointerId: number;
startX: number;
scrollLeft: number;
hasDragged: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const state = dragStateRef.current;
const element = scrollRef.current;
if (!state || !element || state.pointerId !== event.pointerId) return;
if (state.hasDragged) {
suppressClickRef.current = true;
window.setTimeout(() => {
suppressClickRef.current = false;
}, 0);
}
if (element.hasPointerCapture(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
};
return (
<div
ref={scrollRef}
className={cn(
"chat-card-x-scroll w-full min-w-0 overflow-x-auto overscroll-x-contain pb-2",
"cursor-grab select-auto touch-pan-x active:cursor-grabbing",
className
)}
onClickCapture={(event) => {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={endDrag}
onPointerDown={(event) => {
if (event.button !== 0) return;
if (isTableDragInteractiveTarget(event.target)) return;
const element = scrollRef.current;
if (!element || element.scrollWidth <= element.clientWidth) return;
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
scrollLeft: element.scrollLeft,
hasDragged: false,
};
element.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const state = dragStateRef.current;
const element = scrollRef.current;
if (!state || !element || state.pointerId !== event.pointerId) return;
const deltaX = event.clientX - state.startX;
if (Math.abs(deltaX) > 2) {
state.hasDragged = true;
event.preventDefault();
}
element.scrollLeft = state.scrollLeft - deltaX;
}}
onPointerUp={endDrag}
{...props}
>
{children}
</div>
);
}
const OPENCLAW_CHART_BLOCK_REGEX =
/```(?:openclaw-chart|openclaw_chart|claw-chart|claw_chart|chart)\s*\n[\s\S]*?```/i;
const MARKDOWN_TABLE_REGEX =
/(^|\n)\s*\|[^\n|]+(?:\|[^\n|]+)+\|\s*\n\s*\|(?:\s*:?-{3,}:?\s*\|)+/;
export function hasDownloadableReportContent(content: string) {
const normalized = String(content || "");
return (
OPENCLAW_CHART_BLOCK_REGEX.test(normalized) ||
MARKDOWN_TABLE_REGEX.test(normalized)
);
}
function normalizeTableCellText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function escapeCsvCell(value: string) {
const normalized = normalizeTableCellText(value);
const escaped = normalized.replace(/"/g, '""');
return /[",\r\n]/.test(escaped) ? `"${escaped}"` : escaped;
}
function tableToCsv(table: HTMLTableElement) {
return Array.from(table.rows)
.map((row) =>
Array.from(row.cells)
.map((cell) => escapeCsvCell(cell.innerText || ""))
.join(",")
)
.join("\r\n");
}
function downloadTextFile(filename: string, content: string, type: string) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
type ReportCsvSection = {
title: string;
csv: string;
};
function stripMarkdownTableCell(value: string) {
return normalizeTableCellText(
String(value || "")
.replace(/\\\|/g, "|")
.replace(/<br\s*\/?>/gi, " ")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/__(.*?)__/g, "$1")
.replace(/`([^`]+)`/g, "$1")
);
}
function splitMarkdownTableRow(line: string) {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
const cells: string[] = [];
let current = "";
for (let index = 0; index < trimmed.length; index += 1) {
const char = trimmed[index];
const next = trimmed[index + 1];
if (char === "\\" && next === "|") {
current += "|";
index += 1;
continue;
}
if (char === "|") {
cells.push(stripMarkdownTableCell(current));
current = "";
continue;
}
current += char;
}
cells.push(stripMarkdownTableCell(current));
return cells;
}
function isMarkdownTableSeparator(cells: string[]) {
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function getMarkdownTableTitle(lines: string[], tableStartIndex: number, fallback: string) {
for (let index = tableStartIndex - 1; index >= 0; index -= 1) {
const line = lines[index]?.trim() || "";
if (!line) continue;
if (line.includes("|")) break;
const title = line
.replace(/^#{1,6}\s*/, "")
.replace(/^\d+\.\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\*\*(.*?)\*\*$/, "$1")
.replace(/[::]\s*$/, "")
.trim();
if (title) return title;
break;
}
return fallback;
}
function tableRowsToCsv(rows: string[][]) {
return rows
.map((row) => row.map((cell) => escapeCsvCell(cell)).join(","))
.join("\r\n");
}
function extractMarkdownTableCsvSections(content: string): ReportCsvSection[] {
const lines = String(content || "").replace(/\r\n/g, "\n").split("\n");
const sections: ReportCsvSection[] = [];
let inCodeFence = false;
for (let index = 0; index < lines.length - 1; index += 1) {
const currentLine = lines[index] || "";
if (/^\s*```/.test(currentLine)) {
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence || !currentLine.includes("|")) continue;
const header = splitMarkdownTableRow(currentLine);
const separator = splitMarkdownTableRow(lines[index + 1] || "");
if (header.length < 2 || !isMarkdownTableSeparator(separator)) continue;
const rows: string[][] = [header];
let cursor = index + 2;
while (cursor < lines.length) {
const rowLine = lines[cursor] || "";
if (!rowLine.includes("|") || /^\s*```/.test(rowLine)) break;
const row = splitMarkdownTableRow(rowLine);
if (row.length < 2) break;
rows.push(row);
cursor += 1;
}
sections.push({
title: getMarkdownTableTitle(lines, index, `表格 ${sections.length + 1}`),
csv: tableRowsToCsv(rows),
});
index = cursor - 1;
}
return sections;
}
function extractChartCsvSections(content: string): ReportCsvSection[] {
return splitOpenClawChartBlocks(content)
.filter((segment): segment is { type: "chart"; value: Parameters<typeof chartDataToCsv>[0] } =>
segment.type === "chart"
)
.map((segment, index) => ({
title: segment.value.title || `图表 ${index + 1}`,
csv: chartDataToCsv(segment.value),
}));
}
function buildReportTablesCsv(content: string) {
const sections = [
...extractMarkdownTableCsvSections(content),
...extractChartCsvSections(content),
];
return sections
.map((section, index) =>
[
escapeCsvCell(`${index + 1}. ${section.title}`),
section.csv,
].join("\r\n")
)
.join("\r\n\r\n");
}
function normalizeMarkdownContent(value: string) {
return String(value || "")
.replace(/\r\n/g, "\n")
.replace(/[ \t\u00a0]+\n/g, "\n")
.replace(/\n[ \t\u00a0]*\n(?:[ \t\u00a0]*\n)+/g, "\n\n")
.trim();
}
function splitMarkdownImages(content: string) {
const segments: Array<
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string }
> = [];
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
segments.push({
type: "image",
alt: match[1],
src: match[2],
});
lastIndex = imageRegex.lastIndex;
}
if (lastIndex < content.length) {
segments.push({
type: "text",
value: content.slice(lastIndex),
});
}
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 (
<>
<button
type="button"
className="group/table-image relative block h-16 w-16 overflow-hidden rounded-md border border-border/70 bg-muted/40"
data-no-drag="true"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsOpen(true);
}}
title="点击查看大图"
>
<img
src={thumbnailSrc}
alt={alt || "表格图片"}
className="h-full w-full object-contain p-1 transition-transform duration-200 group-hover/table-image:scale-[1.03]"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== src) {
event.currentTarget.src = src;
}
}}
/>
</button>
{isOpen ? (
<div
className="fixed inset-0 z-[260] flex items-center justify-center bg-black/82 p-4"
data-no-drag="true"
onClick={() => setIsOpen(false)}
>
<button
type="button"
className="absolute right-4 top-4 rounded-full bg-white/12 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
onClick={(event) => {
event.stopPropagation();
setIsOpen(false);
}}
>
关闭
</button>
<img
src={largeSrc}
alt={alt || "表格图片大图"}
className="max-h-[90vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
loading="eager"
decoding="async"
onClick={(event) => event.stopPropagation()}
onError={(event) => {
if (event.currentTarget.src !== originalSrc) {
event.currentTarget.src = originalSrc;
}
}}
/>
</div>
) : null}
</>
);
}
function normalizeSectionTitle(line: string) {
let normalized = line.trim();
if (!normalized) return "";
normalized = normalized
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\*\*(.+)\*\*$/, "$1")
.replace(/[▼▽▾]\s*$/, "")
.trim();
const colonIndex = normalized.indexOf(":");
const asciiColonIndex = normalized.indexOf(":");
const splitIndex =
colonIndex >= 0 && asciiColonIndex >= 0
? Math.min(colonIndex, asciiColonIndex)
: Math.max(colonIndex, asciiColonIndex);
if (splitIndex > 0 && splitIndex <= 12) {
normalized = normalized.slice(0, splitIndex).trim();
}
return normalized.replace(/[::]+$/, "").trim();
}
function extractImageAnalysisSection(content: string): AnalysisSection | null {
const lines = content.split(/\r?\n/);
let startIndex = -1;
let inlineAnalysis = "";
for (let i = 0; i < lines.length; i += 1) {
const title = normalizeSectionTitle(lines[i]);
if (!title || !title.startsWith(IMAGE_ANALYSIS_TITLE)) continue;
startIndex = i;
const headingText = lines[i]
.trim()
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\*\*(.+)\*\*$/, "$1")
.trim();
const inlineMatch = headingText.match(/^图片分析[::]\s*(.+)$/);
if (inlineMatch?.[1]) {
inlineAnalysis = inlineMatch[1].trim();
}
break;
}
if (startIndex < 0) return null;
let endIndex = lines.length;
for (let i = startIndex + 1; i < lines.length; i += 1) {
const title = normalizeSectionTitle(lines[i]);
if (!title || title.startsWith(IMAGE_ANALYSIS_TITLE)) continue;
if (AGENT_SECTION_TITLES.has(title)) {
endIndex = i;
break;
}
}
const analysisLines = lines.slice(startIndex + 1, endIndex);
if (inlineAnalysis) {
analysisLines.unshift(inlineAnalysis);
}
const analysis = analysisLines.join("\n").trim();
if (!analysis) return null;
return {
before: lines.slice(0, startIndex).join("\n").trim(),
analysis,
after: lines.slice(endIndex).join("\n").trim(),
};
}
type ImageAnalysisCollapsibleProps = {
content: string;
streamdownClassName: string;
streamdownComponents: ComponentProps<typeof Streamdown>["components"];
};
function ImageAnalysisCollapsible({
content,
streamdownClassName,
streamdownComponents,
}: ImageAnalysisCollapsibleProps) {
const [open, setOpen] = useState(true);
return (
<Collapsible
className="my-3 rounded-[20px] bg-[#f4f5f7] px-5 py-4"
onOpenChange={setOpen}
open={open}
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 text-left">
<span className="inline-flex items-center gap-2">
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-white text-[11px] font-semibold text-[#98a2b3] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.45)]">
Q.
</span>
<span className="text-base font-semibold tracking-tight text-[#9aa1ad]">图片分析</span>
</span>
<ChevronDownIcon
className={cn(
"size-4 shrink-0 text-[#9aa1ad] transition-transform",
open ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-0 pb-0 pt-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
"outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
<Streamdown
className={cn(
streamdownClassName,
"text-[15px] font-medium leading-7 text-[#2f3640] [&_p]:my-0 [&_ul]:my-0 [&_li]:my-0"
)}
components={streamdownComponents}
>
{content}
</Streamdown>
</CollapsibleContent>
</Collapsible>
);
}
function DownloadableMarkdownTable({
children,
className,
node: _node,
...props
}: DownloadableMarkdownTableProps) {
const tableRef = useRef<HTMLTableElement>(null);
const handleDownload = () => {
const table = tableRef.current;
if (!table) return;
const csv = tableToCsv(table);
if (!csv.trim()) return;
const timestamp = new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "");
downloadTextFile(
`chat-table-${timestamp}.csv`,
`\uFEFF${csv}`,
"text/csv;charset=utf-8"
);
};
return (
<div className="my-3 w-full min-w-0 max-w-full overflow-hidden rounded-xl border border-border bg-background/70 whitespace-normal">
<div
className="flex items-center justify-end border-b border-border/70 bg-muted/40 px-2 py-1.5"
data-pdf-exclude="true"
>
<button
type="button"
onClick={handleDownload}
data-pdf-exclude="true"
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
title="下载表格 CSV"
>
<DownloadIcon className="h-3.5 w-3.5" />
<span>下载表格</span>
</button>
</div>
<HorizontalDragScrollArea>
<MarkdownTableContext.Provider value>
<table
ref={tableRef}
className={cn(
"min-w-full table-auto border-collapse text-sm",
"[&_td]:min-w-[10rem] [&_td]:max-w-[24rem] [&_td]:align-top [&_td]:whitespace-normal [&_td]:break-words [&_td]:[overflow-wrap:anywhere] [&_td]:[word-break:break-word]",
"[&_td:first-child]:min-w-[12rem] [&_td:first-child]:max-w-[18rem] [&_th:first-child]:min-w-[12rem] [&_th:first-child]:max-w-[18rem]",
"[&_th]:min-w-[10rem] [&_th]:max-w-[22rem] [&_th]:whitespace-normal [&_th]:break-words [&_th]:[overflow-wrap:anywhere] [&_th]:[word-break:break-word]",
className
)}
{...props}
>
{children}
</table>
</MarkdownTableContext.Provider>
</HorizontalDragScrollArea>
</div>
);
}
function MarkdownTableCell({
children,
className,
node: _node,
...props
}: MarkdownTableCellProps) {
const imageUrl = getImageUrlFromTableText(children);
return (
<td
className={cn(
"min-w-[10rem] max-w-[24rem] align-top !whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
className
)}
{...props}
>
{imageUrl ? <MarkdownTableImagePreview src={imageUrl} /> : children}
</td>
);
}
function MarkdownTableHeaderCell({
className,
node: _node,
...props
}: MarkdownTableHeaderCellProps) {
return (
<th
className={cn(
"min-w-[10rem] max-w-[22rem] !whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
className
)}
{...props}
/>
);
}
function getSafeCsvFileName(value?: string | null) {
const normalized = String(value || "Claw分析报告")
.replace(/\s+/g, " ")
.replace(/[\\/:*?"<>|]/g, "_")
.trim()
.slice(0, 80);
return normalized || "Claw分析报告";
}
function ResponseReportTableDownloadButton({
content,
fileName,
}: {
content: string;
fileName?: string;
}) {
const handleDownload = () => {
const csv = buildReportTablesCsv(content);
if (!csv.trim()) {
console.warn("[Response] no report table data to export");
return;
}
downloadTextFile(
`${getSafeCsvFileName(fileName)}-表格.csv`,
`\uFEFF${csv}`,
"text/csv;charset=utf-8"
);
};
return (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-2.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
onClick={handleDownload}
title="下载整段回复中的所有表格和图表数据"
>
<FileSpreadsheetIcon className="h-3.5 w-3.5" />
<span>下载表格</span>
</button>
);
}
// 注意:移除了 memo 的自定义比较函数,确保 isProcessing* 状态变化时组件能正确重新渲染
// 之前的 memo 比较函数可能导致 Streamdown 内部的 ImagePreview 组件没有正确更新
export const Response = ({
className,
collapsibleImageAnalysis = false,
enablePdfDownload = false,
pdfFileName,
pdfSiteLogo,
pdfSiteName,
pdfTitle,
onImageEdit,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
...props
}: ResponseProps) => {
const pdfContentRef = useRef<HTMLDivElement>(null);
const streamdownClassName = cn(
"w-full min-w-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
className,
MARKDOWN_HEADING_CLASS_NAME,
"whitespace-normal [&_code]:whitespace-pre-wrap"
);
const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}-${isProcessingWatermarkRemove}`;
const streamdownComponents = {
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (!src) {
return null;
}
return (
<MarkdownTableContext.Consumer>
{(isInsideTable) =>
isInsideTable ? (
<MarkdownTableImagePreview alt={alt} src={src} />
) : (
<ImagePreview
key={`${src}-${processingKey}`}
src={src}
alt={alt}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
/>
)
}
</MarkdownTableContext.Consumer>
);
},
video: ({ src, alt }: { src?: string; alt?: string }) => {
if (!src) {
return null;
}
return <VideoPreview src={src} alt={alt} />;
},
table: DownloadableMarkdownTable,
td: MarkdownTableCell,
th: MarkdownTableHeaderCell,
a: ({
children,
className,
href,
onClick,
...linkProps
}: ComponentProps<"a">) => (
<MarkdownTableContext.Consumer>
{(isInsideTable) =>
isInsideTable && isLikelyImageUrl(href) ? (
<MarkdownTableImagePreview
alt={typeof children === "string" ? children : undefined}
src={String(href || "")}
/>
) : (
<a
{...linkProps}
className={cn(
"font-medium text-primary underline underline-offset-2",
className
)}
href={href}
onClick={(event) => {
event.stopPropagation();
onClick?.(event);
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
)
}
</MarkdownTableContext.Consumer>
),
};
const renderImagePreview = (
src: string,
alt: string | undefined,
key: string,
compactGrid = false
) => (
<ImagePreview
key={key}
src={src}
alt={alt}
compactGrid={compactGrid}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
/>
);
const renderImageGrid = (
images: Array<{ alt?: string; src: string }>,
keyPrefix: string
) => {
if (images.length === 0) return null;
if (images.length === 1) {
const image = images[0];
return renderImagePreview(
image.src,
image.alt,
`${image.src}-${processingKey}-${keyPrefix}-single`
);
}
const gridClassName =
images.length === 2
? "grid-cols-2 sm:max-w-[640px]"
: images.length === 3
? "grid-cols-2 sm:grid-cols-3 sm:max-w-[780px]"
: "grid-cols-2 sm:grid-cols-4 sm:max-w-[920px]";
return (
<div
className={cn(
"not-prose my-3 grid w-full gap-1.5 overflow-hidden rounded-lg",
gridClassName
)}
key={`${keyPrefix}-image-grid`}
>
{images.map((image, index) => (
<div className="min-w-0" key={`${image.src}-${index}`}>
{renderImagePreview(
image.src,
image.alt,
`${image.src}-${processingKey}-${keyPrefix}-grid-${index}`,
true
)}
</div>
))}
</div>
);
};
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
if (!text.trim()) return null;
if (
!MARKDOWN_TABLE_REGEX.test(text) &&
text.includes("![") &&
text.includes("](")
) {
const segments = splitMarkdownImages(text);
const hasImageSegments = segments.some((segment) => segment.type === "image");
if (hasImageSegments) {
const nodes: ReactNode[] = [];
let pendingImages: Array<{ alt?: string; src: string }> = [];
const flushImages = (index: number) => {
if (pendingImages.length === 0) return;
nodes.push(
renderImageGrid(pendingImages, `${keyPrefix}-images-${index}`)
);
pendingImages = [];
};
segments.forEach((segment, index) => {
if (segment.type === "image") {
pendingImages.push({
alt: segment.alt,
src: segment.src,
});
return;
}
if (!segment.value.trim()) {
return;
}
flushImages(index);
nodes.push(
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
key={`${keyPrefix}-text-${index}`}
>
{segment.value}
</Streamdown>
);
});
flushImages(segments.length);
return nodes;
}
}
return (
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
key={`${keyPrefix}-single`}
>
{text}
</Streamdown>
);
};
const renderTextWithImages = (text: string, keyPrefix: string): ReactNode => {
if (!text.trim()) return null;
const chartSegments = splitOpenClawChartBlocks(text);
const hasChartSegments = chartSegments.some((segment) => segment.type === "chart");
if (!hasChartSegments) {
return renderTextSegment(text, keyPrefix);
}
return chartSegments.map((segment, index) => {
if (segment.type === "chart") {
return (
<OpenClawChartCard
chart={segment.value}
key={`${keyPrefix}-chart-${index}`}
/>
);
}
return renderTextSegment(segment.value, `${keyPrefix}-text-${index}`);
});
};
const rawText =
typeof props.children === "string"
? normalizeMarkdownContent(props.children)
: null;
const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
const wrapPdfDownload = (content: ReactNode) => {
if (!shouldShowPdfDownload) return content;
return (
<div className="w-full min-w-0">
<div ref={pdfContentRef}>{content}</div>
<div
className="not-prose mt-4 flex flex-wrap justify-end gap-2"
data-pdf-exclude="true"
>
<ResponseReportTableDownloadButton
content={rawText || ""}
fileName={pdfFileName}
/>
<ResponsePdfDownloadButton
contentRef={pdfContentRef}
fileName={pdfFileName}
siteLogo={pdfSiteLogo}
siteName={pdfSiteName}
title={pdfTitle}
/>
</div>
</div>
);
};
if (rawText) {
const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText)
: null;
if (analysisSection) {
return wrapPdfDownload(
<div className={streamdownClassName}>
{renderTextWithImages(analysisSection.before, "analysis-before")}
<ImageAnalysisCollapsible
content={analysisSection.analysis}
streamdownClassName={streamdownClassName}
streamdownComponents={streamdownComponents}
/>
{renderTextWithImages(analysisSection.after, "analysis-after")}
</div>
);
}
return wrapPdfDownload(
<div className={streamdownClassName}>{renderTextWithImages(rawText, "raw")}</div>
);
}
return (
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
{...props}
/>
);
};
Response.displayName = "Response";