"use client"; import { ChevronDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; import { useState } from "react"; import { Streamdown } from "streamdown"; import { ImagePreview } from "@/components/image-preview"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { VideoPreview } from "@/components/video-preview"; import { cn } from "@/lib/utils"; type ResponseProps = ComponentProps & { collapsibleImageAnalysis?: boolean; 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, "执行计划", "输出设置", ]); type AnalysisSection = { before: string; analysis: string; after: string; }; 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["components"]; }; function ImageAnalysisCollapsible({ content, streamdownClassName, streamdownComponents, }: ImageAnalysisCollapsibleProps) { const [open, setOpen] = useState(false); return ( 图片分析 {content} ); } // 注意:移除了 memo 的自定义比较函数,确保 isProcessing* 状态变化时组件能正确重新渲染 // 之前的 memo 比较函数可能导致 Streamdown 内部的 ImagePreview 组件没有正确更新 export const Response = ({ className, collapsibleImageAnalysis = false, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, onImageExtend, onImageAngleEdit, onImageWatermarkRemove, isProcessingEnhance, isProcessingRmbg, isProcessingExtend, isProcessingAngleEdit, isProcessingWatermarkRemove, ...props }: ResponseProps) => { const streamdownClassName = cn( "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto", className ); const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}-${isProcessingWatermarkRemove}`; const streamdownComponents = { img: ({ src, alt }: { src?: string; alt?: string }) => { if (!src) { return null; } return ( ); }, video: ({ src, alt }: { src?: string; alt?: string }) => { if (!src) { return null; } return ; }, }; const renderImagePreview = (src: string, alt: string | undefined, key: string) => ( ); const renderTextWithImages = (text: string, keyPrefix: string): ReactNode => { if (!text.trim()) return null; if (text.includes("![") && 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 ( {segment.value} ); }); } } return ( {text} ); }; const rawText = typeof props.children === "string" ? props.children : null; if (rawText) { const analysisSection = collapsibleImageAnalysis ? extractImageAnalysisSection(rawText) : null; if (analysisSection) { return (
{renderTextWithImages(analysisSection.before, "analysis-before")} {renderTextWithImages(analysisSection.after, "analysis-after")}
); } return
{renderTextWithImages(rawText, "raw")}
; } return ( ); }; Response.displayName = "Response";