lovart3/components/elements/response.tsx
2026-02-08 23:02:54 +08:00

360 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<typeof Streamdown> & {
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<typeof Streamdown>["components"];
};
function ImageAnalysisCollapsible({
content,
streamdownClassName,
streamdownComponents,
}: ImageAnalysisCollapsibleProps) {
const [open, setOpen] = useState(false);
return (
<Collapsible
className="my-2 rounded-xl border border-border/60 bg-muted/30"
onOpenChange={setOpen}
open={open}
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
<span className="text-sm font-medium text-foreground">图片分析</span>
<ChevronDownIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
open ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"border-border/50 border-t px-3 pb-3 pt-2 text-sm text-muted-foreground",
"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={streamdownClassName} components={streamdownComponents}>
{content}
</Streamdown>
</CollapsibleContent>
</Collapsible>
);
}
// 注意:移除了 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 (
<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} />;
},
};
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 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 (
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
key={`${keyPrefix}-text-${index}`}
>
{segment.value}
</Streamdown>
);
});
}
}
return (
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
key={`${keyPrefix}-single`}
>
{text}
</Streamdown>
);
};
const rawText = typeof props.children === "string" ? props.children : null;
if (rawText) {
const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText)
: null;
if (analysisSection) {
return (
<div className={streamdownClassName}>
{renderTextWithImages(analysisSection.before, "analysis-before")}
<ImageAnalysisCollapsible
content={analysisSection.analysis}
streamdownClassName={streamdownClassName}
streamdownComponents={streamdownComponents}
/>
{renderTextWithImages(analysisSection.after, "analysis-after")}
</div>
);
}
return <div className={streamdownClassName}>{renderTextWithImages(rawText, "raw")}</div>;
}
return (
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
{...props}
/>
);
};
Response.displayName = "Response";