"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 & { 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(null); const dragStateRef = useRef<{ pointerId: number; startX: number; scrollLeft: number; hasDragged: boolean; } | null>(null); const suppressClickRef = useRef(false); const endDrag = (event: ReactPointerEvent) => { 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 (
{ 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}
); } 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(//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[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 ( <> {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 ""; 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["components"]; }; function ImageAnalysisCollapsible({ content, streamdownClassName, streamdownComponents, }: ImageAnalysisCollapsibleProps) { const [open, setOpen] = useState(true); return ( Q. 图片分析 {content} ); } function DownloadableMarkdownTable({ children, className, node: _node, ...props }: DownloadableMarkdownTableProps) { const tableRef = useRef(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 (
{children}
); } function MarkdownTableCell({ children, className, node: _node, ...props }: MarkdownTableCellProps) { const imageUrl = getImageUrlFromTableText(children); return ( {imageUrl ? : children} ); } function MarkdownTableHeaderCell({ className, node: _node, ...props }: MarkdownTableHeaderCellProps) { return ( ); } 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 ( ); } // 注意:移除了 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(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 ( {(isInsideTable) => isInsideTable ? ( ) : ( ) } ); }, video: ({ src, alt }: { src?: string; alt?: string }) => { if (!src) { return null; } return ; }, table: DownloadableMarkdownTable, td: MarkdownTableCell, th: MarkdownTableHeaderCell, a: ({ children, className, href, onClick, ...linkProps }: ComponentProps<"a">) => ( {(isInsideTable) => isInsideTable && isLikelyImageUrl(href) ? ( ) : ( { event.stopPropagation(); onClick?.(event); }} rel="noreferrer" target="_blank" > {children} ) } ), }; const renderImagePreview = ( src: string, alt: string | undefined, key: string, compactGrid = false ) => ( ); 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 (
{images.map((image, index) => (
{renderImagePreview( image.src, image.alt, `${image.src}-${processingKey}-${keyPrefix}-grid-${index}`, true )}
))}
); }; 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( {segment.value} ); }); flushImages(segments.length); return nodes; } } return ( {text} ); }; 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 ( ); } 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 (
{content}
); }; if (rawText) { const analysisSection = collapsibleImageAnalysis ? extractImageAnalysisSection(rawText) : null; if (analysisSection) { return wrapPdfDownload(
{renderTextWithImages(analysisSection.before, "analysis-before")} {renderTextWithImages(analysisSection.after, "analysis-after")}
); } return wrapPdfDownload(
{renderTextWithImages(rawText, "raw")}
); } return ( ); }; Response.displayName = "Response";