WuYongJie 61e2bbe0e4 修复粒子动画加载与生图 524 错误展示
风格化粒子动画依赖外部 CDN 的 Three.js,全站同域脚本策略会阻止其加载,导致 THREE 未定义。现在使用同域 ES module,保留原 Three.js 0.160.0、动画参数及 MIT 许可。

扶苏在 2026-11-04 09:44 收到的 gpt-image-2.5 HTTP 524 响应是 Cloudflare HTML 页面,错误正文被直接保存和显示。现在将这类响应显示为结果未确认的超时提示;已有历史消息在渲染时同样归一化,不改动原始记录。524 不触发自动模型切换,避免原任务结果未确认时再次生成。普通 HTML 文档和代码示例保持原样。

验证:原生产 CSP 下的本地浏览器验证粒子有可见像素、连续帧变化、窗口缩放及无外部请求、无脚本错误;637 项辅助测试通过、1 项原有跳过,包含供应商 524 只提交一次、结果未确认、不自动切换及正常 503 行为。TypeScript、改动文件 lint、凭据、日志脱敏、环境、元数据、测试与制品检查通过。

已知问题:全量 lint 仍有 1004 项历史诊断。524 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
2026-11-04 10:26:01 +08:00

1099 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 { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
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;
isMarkdownStreaming?: 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 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] border-r border-border/60 px-4 py-2.5 align-top !whitespace-normal break-words last:border-r-0 [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] border-r border-border/60 px-4 py-2.5 align-top !whitespace-normal break-words last:border-r-0 [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,
isMarkdownStreaming = 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(stableStreamingMarkdown(
getGatewayErrorPageMessage(props.children) || props.children,
isMarkdownStreaming
))
: 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 !== null) {
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";