优化自动模式图片分析折叠展示

This commit is contained in:
Your Name 2026-02-08 23:02:54 +08:00
parent dc7cca846f
commit 1092db2b88
3 changed files with 244 additions and 62 deletions

View File

@ -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<typeof Streamdown> & {
collapsibleImageAnalysis?: boolean;
onImageEdit?: (imageUrl: string) => void;
onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void;
@ -21,6 +29,20 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
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,10 +80,131 @@ 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<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,
@ -116,21 +259,11 @@ 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");
if (hasImageSegments) {
return (
<div className={streamdownClassName}>
{segments.map((segment, index) => {
if (segment.type === "image") {
return (
const renderImagePreview = (src: string, alt: string | undefined, key: string) => (
<ImagePreview
key={`${segment.src}-${processingKey}-${index}`}
src={segment.src}
alt={segment.alt}
key={key}
src={src}
alt={alt}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
@ -145,6 +278,22 @@ export const Response = ({
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()) {
@ -155,15 +304,47 @@ export const Response = ({
<Streamdown
className={streamdownClassName}
components={streamdownComponents}
key={`text-${index}`}
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 (

View File

@ -312,6 +312,7 @@ const PurePreviewMessage = ({
return (
<Response
key={`${partIndex}-${processingKey}`}
collapsibleImageAnalysis={message.role === "assistant"}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}

View File

@ -134,11 +134,11 @@ async function generateAgentConversation(
2) 语气自然、专业,不要模板腔,不要空话。
3) 不暴露内部实现、模型路由、算法细节。
4) 输出 Markdown,控制在 8-14 行。
5) 有图片时,必须包含以下小节标题且按顺序输出:
- 需求理解
- 图片分析
- 执行计划
- 输出设置
5) 有图片时,必须按顺序输出以下 Markdown H3 标题(逐字一致):
- ### 需求理解
- ### 图片分析
- ### 执行计划
- ### 输出设置
6) “图片分析”要结合每张参考图差异,给出风格/构图/材质/光影等具体观察。`;
const prompt = [