688 lines
20 KiB
TypeScript
688 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { ChevronDownIcon, DownloadIcon } from "lucide-react";
|
||
import type {
|
||
ComponentProps,
|
||
PointerEvent as ReactPointerEvent,
|
||
ReactNode,
|
||
} from "react";
|
||
import { useRef, useState } from "react";
|
||
import { Streamdown } from "streamdown";
|
||
import { ImagePreview } from "@/components/image-preview";
|
||
import {
|
||
OpenClawChartCard,
|
||
splitOpenClawChartBlocks,
|
||
} from "@/components/elements/openclaw-chart-card";
|
||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||
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;
|
||
};
|
||
|
||
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;
|
||
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);
|
||
}
|
||
|
||
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 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>
|
||
<table
|
||
ref={tableRef}
|
||
className={cn(
|
||
"w-max min-w-full table-auto border-collapse text-sm",
|
||
"[&_td]:min-w-[12rem] [&_td]:align-top [&_td]:whitespace-nowrap",
|
||
"[&_td:first-child]:min-w-[22rem] [&_th:first-child]:min-w-[22rem]",
|
||
"[&_th]:min-w-[12rem] [&_th]:whitespace-nowrap",
|
||
className
|
||
)}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</table>
|
||
</HorizontalDragScrollArea>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MarkdownTableCell({
|
||
className,
|
||
node: _node,
|
||
...props
|
||
}: MarkdownTableCellProps) {
|
||
return (
|
||
<td
|
||
className={cn("min-w-[12rem] align-top !whitespace-nowrap", className)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function MarkdownTableHeaderCell({
|
||
className,
|
||
node: _node,
|
||
...props
|
||
}: MarkdownTableHeaderCellProps) {
|
||
return (
|
||
<th
|
||
className={cn("min-w-[12rem] !whitespace-nowrap", className)}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 注意:移除了 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 (
|
||
<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}
|
||
/>
|
||
);
|
||
},
|
||
video: ({ src, alt }: { src?: string; alt?: string }) => {
|
||
if (!src) {
|
||
return null;
|
||
}
|
||
return <VideoPreview src={src} alt={alt} />;
|
||
},
|
||
table: DownloadableMarkdownTable,
|
||
td: MarkdownTableCell,
|
||
th: MarkdownTableHeaderCell,
|
||
};
|
||
|
||
const renderImagePreview = (src: string, alt: string | undefined, key: string) => (
|
||
<ImagePreview
|
||
key={key}
|
||
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}
|
||
/>
|
||
);
|
||
|
||
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
|
||
if (!text.trim()) return null;
|
||
|
||
if (text.includes(") {
|
||
const segments = splitMarkdownImages(text);
|
||
const hasImageSegments = segments.some((segment) => segment.type === "image");
|
||
|
||
if (hasImageSegments) {
|
||
return segments.map((segment, index) => {
|
||
if (segment.type === "image") {
|
||
return renderImagePreview(
|
||
segment.src,
|
||
segment.alt,
|
||
`${segment.src}-${processingKey}-${keyPrefix}-${index}`
|
||
);
|
||
}
|
||
|
||
if (!segment.value.trim()) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Streamdown
|
||
className={streamdownClassName}
|
||
components={streamdownComponents}
|
||
key={`${keyPrefix}-text-${index}`}
|
||
>
|
||
{segment.value}
|
||
</Streamdown>
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
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 justify-end"
|
||
data-pdf-exclude="true"
|
||
>
|
||
<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";
|