diff --git a/components/elements/response.tsx b/components/elements/response.tsx index a8fedd8..0e2fc2d 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -1,12 +1,20 @@ "use client"; -import type { ComponentProps } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { useState } from "react"; import { Streamdown } from "streamdown"; -import { cn } from "@/lib/utils"; 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; @@ -21,6 +29,20 @@ type ResponseProps = ComponentProps & { 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 } @@ -58,23 +80,144 @@ function splitMarkdownImages(content: string) { 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, - onImageEdit, - onDrawingComplete, - onImageEnhance, - onImageRmbg, - onImageExtend, - onImageAngleEdit, +export const Response = ({ + className, + collapsibleImageAnalysis = false, + onImageEdit, + onDrawingComplete, + onImageEnhance, + onImageRmbg, + onImageExtend, + onImageAngleEdit, onImageWatermarkRemove, - isProcessingEnhance, - isProcessingRmbg, - isProcessingExtend, - isProcessingAngleEdit, + isProcessingEnhance, + isProcessingRmbg, + isProcessingExtend, + isProcessingAngleEdit, isProcessingWatermarkRemove, - ...props + ...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", @@ -116,54 +259,92 @@ export const Response = ({ }, }; - const rawText = typeof props.children === "string" ? props.children : null; - if (rawText && rawText.includes("![") && rawText.includes("](")) { - const segments = splitMarkdownImages(rawText); - const hasImageSegments = segments.some((segment) => segment.type === "image"); + const renderImagePreview = (src: string, alt: string | undefined, key: string) => ( + + ); - if (hasImageSegments) { + 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 (
- {segments.map((segment, index) => { - if (segment.type === "image") { - return ( - - ); - } - - if (!segment.value.trim()) { - return null; - } - - return ( - - {segment.value} - - ); - })} + {renderTextWithImages(analysisSection.before, "analysis-before")} + + {renderTextWithImages(analysisSection.after, "analysis-after")}
); } + + return
{renderTextWithImages(rawText, "raw")}
; } return ( diff --git a/components/message.tsx b/components/message.tsx index bfb5fd8..c3cde20 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -312,6 +312,7 @@ const PurePreviewMessage = ({ return (