461 lines
13 KiB
TypeScript
461 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { ChevronDownIcon, DownloadIcon } from "lucide-react";
|
||
import type { ComponentProps, ReactNode } from "react";
|
||
import { useRef, 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;
|
||
};
|
||
|
||
type DownloadableMarkdownTableProps = ComponentProps<"table"> & {
|
||
node?: unknown;
|
||
};
|
||
|
||
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 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-4 max-w-full overflow-hidden rounded-xl border border-border bg-background/70">
|
||
<div className="flex items-center justify-end border-b border-border/70 bg-muted/40 px-2 py-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={handleDownload}
|
||
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>
|
||
<div className="overflow-x-auto">
|
||
<table
|
||
ref={tableRef}
|
||
className={cn("w-full border-collapse text-sm", className)}
|
||
{...props}
|
||
>
|
||
{children}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 注意:移除了 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(
|
||
"w-full min-w-0 [&>*: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} />;
|
||
},
|
||
table: DownloadableMarkdownTable,
|
||
};
|
||
|
||
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(") {
|
||
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";
|