486 lines
14 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, 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,
"执行计划",
"优化后提示词",
"输出设置",
]);
const MARKDOWN_HEADING_CLASS_NAME =
"[&_h1]:!mb-3 [&_h1]:!mt-4 [&_h1]:!text-[26px] [&_h1]:!font-semibold [&_h1]:!leading-[1.25] [&_h1]:!tracking-normal " +
"[&_h2]:!mb-2.5 [&_h2]:!mt-4 [&_h2]:!text-[22px] [&_h2]:!font-semibold [&_h2]:!leading-[1.3] [&_h2]:!tracking-normal " +
"[&_h3]:!mb-2 [&_h3]:!mt-3 [&_h3]:!text-[18px] [&_h3]:!font-semibold [&_h3]:!leading-[1.35] [&_h3]:!tracking-normal " +
"[&_h4]:!mb-1.5 [&_h4]:!mt-3 [&_h4]:!text-[16px] [&_h4]:!font-semibold [&_h4]:!leading-[1.4] [&_h4]:!tracking-normal " +
"[&_h5]:!text-[15px] [&_h5]:!font-semibold [&_h5]:!leading-[1.4] [&_h5]:!tracking-normal " +
"[&_h6]:!text-[14px] [&_h6]:!font-semibold [&_h6]:!leading-[1.4] [&_h6]:!tracking-normal";
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 normalizeMarkdownContent(value: string) {
return String(value || "")
.replace(/\r\n/g, "\n")
.replace(/[ \t\u00a0]+\n/g, "\n")
.replace(/\n[ \t\u00a0]*\n(?:[ \t\u00a0]*\n)+/g, "\n\n")
.trim();
}
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-3 max-w-full overflow-hidden rounded-xl border border-border bg-background/70 whitespace-normal">
<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="max-w-full overflow-x-auto overscroll-x-contain">
<table
ref={tableRef}
className={cn(
"w-max min-w-full border-collapse text-sm",
"[&_td]:min-w-32 [&_td]:align-top [&_td]:break-words [&_td]:whitespace-normal",
"[&_th]:min-w-32 [&_th]:whitespace-nowrap",
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]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
className,
MARKDOWN_HEADING_CLASS_NAME,
"whitespace-normal [&_code]:whitespace-pre-wrap"
);
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("![") && 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"
? normalizeMarkdownContent(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";