1132 lines
33 KiB
TypeScript
1132 lines
33 KiB
TypeScript
"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-3 [&_h1]:!mt-4 [&_h1]:!text-[26px] [&_h1]:!font-semibold [&_h1]:!leading-[1.25] [&_h1]:!tracking-normal " +
|
||
"[&_h2]:!mb-2.5 [&_h2]:!mt-4 [&_h2]:!text-[22px] [&_h2]:!font-semibold [&_h2]:!leading-[1.3] [&_h2]:!tracking-normal " +
|
||
"[&_h3]:!mb-2 [&_h3]:!mt-3 [&_h3]:!text-[18px] [&_h3]:!font-semibold [&_h3]:!leading-[1.35] [&_h3]:!tracking-normal " +
|
||
"[&_h4]:!mb-1.5 [&_h4]:!mt-3 [&_h4]:!text-[16px] [&_h4]:!font-semibold [&_h4]:!leading-[1.4] [&_h4]:!tracking-normal " +
|
||
"[&_h5]:!text-[15px] [&_h5]:!font-semibold [&_h5]:!leading-[1.4] [&_h5]:!tracking-normal " +
|
||
"[&_h6]:!text-[14px] [&_h6]:!font-semibold [&_h6]:!leading-[1.4] [&_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-24 w-24 overflow-hidden rounded-lg 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(
|
||
"w-max min-w-full table-auto border-collapse text-sm leading-6",
|
||
"[&_tr]:border-b [&_tr]:border-border/70 [&_tr:last-child]:border-b-0",
|
||
"[&_tbody_tr:nth-child(even)]:bg-muted/15 [&_tbody_tr:hover]:bg-muted/30",
|
||
"[&_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 text-left align-top leading-6 last:border-r-0",
|
||
"!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] border-r border-border/70 bg-muted/70 px-4 py-2.5 text-left align-middle font-semibold leading-5 last:border-r-0",
|
||
"!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("
|
||
) {
|
||
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";
|