ai-chatbot/components/canvas/CanvasChatPanel.tsx
Your Name ec988be30a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Update agent workflow and video task handling
2026-03-13 11:21:42 +08:00

9864 lines
388 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 { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useSession } from "next-auth/react";
import { createPortal } from "react-dom";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
extractImageUrlsFromText,
extractOriginalImageUrlFromAlt,
inferOriginalImageUrl,
} from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { Attachment, ChatMessage } from "@/lib/types";
import type { AgentPreset } from "@/types/agent";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import {
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
// 标签悬停预览信息
interface TagPreviewInfo {
url: string;
name: string;
x: number; // 标签中心x位置
y: number; // 标签顶部y位置
// 聊天面板容器边界
containerBounds?: {
left: number;
right: number;
top: number;
bottom: number;
};
// 物品标签额外信息
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
};
}
type CanvasMediaStreamPayload = {
messageId?: string;
imageUrls?: string[];
videoUrls?: string[];
prompt?: string;
sourceImageUrls?: string[];
agentPresetId?: string;
agentPresetName?: string;
agentCategoryId?: string;
agentCategoryName?: string;
agentExecutionSnapshotId?: string;
};
type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;
modelName?: string;
imageCount?: number;
};
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
onClose?: () => void;
onOpenFileList?: () => void;
selectedAgentPresetId?: string | null;
chatScope?: "canvas" | "agent";
}
type VideoStoryboardStreamData =
| {
stage: "detecting";
provider?: string;
message?: string;
}
| {
stage: "start";
provider?: string;
taskId: string;
totalShots: number;
gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3";
rows?: number;
cols?: number;
confidence?: number;
sourceImageUrl?: string;
shots?: Array<{
shotIndex: number;
tileImageUrl?: string;
prompt?: string;
}>;
}
| {
stage: "hd_shot_completed";
provider?: string;
taskId: string;
shotIndex: number;
imageUrl: string;
prompt?: string;
}
| {
stage: "hd_shot_failed";
provider?: string;
taskId: string;
shotIndex: number;
error?: string;
}
| {
stage: "awaiting_confirmation";
provider?: string;
taskId: string;
totalShots: number;
gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3";
rows?: number;
cols?: number;
message?: string;
shots?: Array<{
shotIndex: number;
tileImageUrl?: string;
previewImageUrl?: string;
prompt?: string;
}>;
}
| {
stage: "shot_completed";
provider?: string;
taskId: string;
shotIndex: number;
modelId?: string;
videoUrl: string;
text?: string;
}
| {
stage: "shot_failed";
provider?: string;
taskId: string;
shotIndex: number;
error?: string;
}
| {
stage: "completed";
provider?: string;
taskId: string;
totalShots?: number;
successCount?: number;
failedCount?: number;
}
| {
stage: "failed";
provider?: string;
taskId: string;
error?: string;
}
| {
stage: "shot_retry";
provider?: string;
taskId: string;
shotIndex: number;
attempt?: number;
modelId?: string;
error?: string;
};
// 建议卡片数据 - 已移除
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();
if (!trimmed) return "";
const sanitizedPrefix = fixedPrefix.trim();
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
return trimmed.slice(sanitizedPrefix.length).trimStart();
}
const legacyPrefixRemoved = trimmed
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
.replace(/^让\s*lovart\s*/i, "")
.trimStart();
if (legacyPrefixRemoved !== trimmed) {
return legacyPrefixRemoved;
}
return trimmed;
}
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// 限制最大尺寸,避免节点过大
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
// 如果图片太大,按比例缩小
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
// 设置最小尺寸
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
// 加载失败时使用默认尺寸
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
// 从消息中提取图片 URL(只提取原始图片,避免重复)
function extractImageUrls(text: string): string[] {
if (!/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/i.test(text || "")) {
return [];
}
return extractImageUrlsFromText(text);
}
function extractPlainImageUrls(text: string): string[] {
if (!text) return [];
const matches = String(text).match(
/(https?:\/\/[^\s"'<>]+?\.(?:png|jpe?g|webp|gif|bmp|tiff?|svg)(?:\?[^\s"'<>]+)?)/gi
);
return matches ? Array.from(new Set(matches)) : [];
}
function buildPreviewImageUrl(url: string, width: number): string {
return buildImageProxyUrl(url, {
width,
quality: 86,
format: "webp",
});
}
function normalizeMediaUrl(url: string): string {
const raw = String(url || "").trim();
if (!raw) return "";
try {
const parsed = new URL(raw);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
} catch {
return raw
.split("#")[0]
.split("?")[0]
.replace(/\/+$/, "")
.toLowerCase();
}
}
function normalizeImageIdentityUrl(url: string): string {
const raw = String(url || "").trim();
if (!raw) return "";
const canonical = inferOriginalImageUrl(raw);
return normalizeMediaUrl(canonical || raw);
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
if (candidate) return candidate;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
if (candidate) return candidate;
}
return fallback;
}
function getAssistantImageNodeIdentityKey(node: any): string {
if (!node || node.type !== "image") return "";
const data = ((node.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
if (!sourceMessageId) return "";
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceImageKey) return "";
return `${sourceMessageId}::${sourceImageKey}`;
}
function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set<string> {
const urlKeys = new Set<string>();
if (!message) return urlKeys;
const pushUrl = (url?: string | null) => {
const raw = String(url || "").trim();
if (!raw) return;
const normalized = normalizeMediaUrl(raw);
if (!normalized) return;
urlKeys.add(normalized);
};
const parts = (message as any).parts || [];
for (const part of parts) {
if (!part) continue;
if (
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
(typeof part.url === "string" || typeof part.data === "string")
) {
pushUrl(typeof part.url === "string" ? part.url : part.data);
}
if (part.type === "text" && typeof part.text === "string") {
extractImageUrls(part.text).forEach((url) => pushUrl(url));
extractPlainImageUrls(part.text).forEach((url) => pushUrl(url));
}
}
const attachments = (message as any).attachments || [];
for (const attachment of attachments) {
pushUrl(attachment?.url);
}
return urlKeys;
}
function collectMessageImageUrls(message: ChatMessage | undefined): string[] {
const urls: string[] = [];
const seen = new Set<string>();
if (!message) return urls;
const pushUrl = (url?: string | null) => {
const raw = String(url || "").trim();
if (!raw) return;
const normalized = normalizeMediaUrl(raw);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
urls.push(raw);
};
const parts = (message as any).parts || [];
for (const part of parts) {
if (!part) continue;
if (
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
(typeof part.url === "string" || typeof part.data === "string")
) {
const direct = typeof part.url === "string" ? part.url : part.data;
pushUrl(inferOriginalImageUrl(String(direct || "").trim()));
}
if (part.type === "text" && typeof part.text === "string") {
extractImageUrls(part.text).forEach((url) => pushUrl(url));
extractPlainImageUrls(part.text).forEach((url) => pushUrl(url));
}
}
const attachments = (message as any).attachments || [];
for (const attachment of attachments) {
pushUrl(inferOriginalImageUrl(String(attachment?.url || "").trim()));
}
const legacyContent =
typeof (message as any).content === "string" ? (message as any).content : "";
if (legacyContent) {
extractImageUrls(legacyContent).forEach((url) => pushUrl(url));
extractPlainImageUrls(legacyContent).forEach((url) => pushUrl(url));
}
return urls;
}
function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: number): Set<string> {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
return collectMessageImageUrlKeys(message);
}
return new Set<string>();
}
function getNearestUserImageUrls(messages: ChatMessage[], assistantIndex: number): string[] {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
return collectMessageImageUrls(message);
}
return [];
}
function extractAssistantImageUrls(text: string, referenceUrlKeys?: Set<string>): string[] {
const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim;
const markdownUrls: string[] = [];
const seenMarkdownUrls = new Set<string>();
let match: RegExpExecArray | null = null;
while ((match = markdownPattern.exec(text)) !== null) {
const altText = String(match[1] || "");
const markdownSrc = String(match[2] || "").trim();
if (!markdownSrc) continue;
const originalFromAlt = extractOriginalImageUrlFromAlt(altText, markdownSrc);
const candidates = [originalFromAlt, inferOriginalImageUrl(markdownSrc), markdownSrc];
let selected = "";
for (const candidate of candidates) {
const normalized = normalizeMediaUrl(candidate);
if (!normalized) continue;
if (referenceUrlKeys?.size && referenceUrlKeys.has(normalized)) continue;
selected = candidate;
break;
}
if (!selected) {
selected = candidates.find((candidate) => normalizeMediaUrl(candidate)) || "";
}
if (!selected) continue;
const dedupeKey = normalizeMediaUrl(selected);
if (!dedupeKey || seenMarkdownUrls.has(dedupeKey)) continue;
seenMarkdownUrls.add(dedupeKey);
markdownUrls.push(selected);
}
if (markdownUrls.length > 0) {
return markdownUrls;
}
const urls = extractImageUrls(text);
if (!referenceUrlKeys || referenceUrlKeys.size === 0) return urls;
return urls.filter((url) => {
const normalized = normalizeMediaUrl(url);
if (!normalized) return false;
return !referenceUrlKeys.has(normalized);
});
}
function extractVideoUrls(text: string): string[] {
if (!text) return [];
const urls = new Set<string>();
const videoTagPattern = /<video[^>]+src="([^"]+)"/gi;
let match: RegExpExecArray | null = null;
while ((match = videoTagPattern.exec(text)) !== null) {
if (match[1]) urls.add(match[1]);
}
const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi;
while ((match = markdownVideoPattern.exec(text)) !== null) {
if (match[1]) urls.add(match[1]);
}
const plainVideoPattern = /(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/gi;
while ((match = plainVideoPattern.exec(text)) !== null) {
if (match[1]) urls.add(match[1]);
}
return Array.from(urls);
}
function stripMediaMarkup(text: string): string {
return String(text || "")
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
.replace(/<video[\s\S]*?<\/video>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "")
.replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "")
.trim();
}
function extractCanvasModelName(text: string): string {
const source = String(text || "");
if (!source) return "";
const badgeMatch = source.match(
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im
);
if (badgeMatch?.[1]) {
return badgeMatch[1].trim();
}
const routedMatch = source.match(
/^\s*(?:Routed to|Using model)\s*[::]\s*([^\n(]+).*$/im
);
if (routedMatch?.[1]) {
return routedMatch[1].trim();
}
return "";
}
function stripCanvasModelRoutingLines(text: string): string {
return String(text || "")
.replace(/^\s*(?:Routed to|Using model)\s*[::].*$/gim, "")
.replace(/^\s*[◈◇◆🔹⬡✦•·]\s*[^\n]+\s*$/gim, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function messageHasAssistantMedia(
message: ChatMessage | undefined,
referenceUrlKeys?: Set<string>
): boolean {
if (!message || message.role !== "assistant") return false;
const textParts = (message.parts || []).filter(
(part: any) => part.type === "text" && part.text
);
return textParts.some((part: any) => {
const text = String(part.text || "");
return (
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
extractVideoUrls(text).length > 0
);
});
}
function getMessageTimestamp(message: ChatMessage): number | null {
const rawValue =
(message as any).createdAt ??
(message as any).created_at ??
(message as any).timestamp ??
null;
if (rawValue instanceof Date) {
return rawValue.getTime();
}
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
return rawValue;
}
if (typeof rawValue === "string") {
const parsed = Date.parse(rawValue);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
function getMessageDayKey(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatMessageDateLabel(timestamp: number): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(timestamp));
}
const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析";
const CANVAS_IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
const CANVAS_AGENT_SECTION_TITLES = new Set([
"需求理解",
CANVAS_IMAGE_ANALYSIS_TITLE,
"执行计划",
"优化后提示词",
"输出设置",
]);
const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000;
type CanvasImageGenerationRequest = {
prompt: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
negativePrompt?: string;
referenceImage?: string;
referenceImages?: string[];
};
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function requestCanvasImageGeneration(
payload: CanvasImageGenerationRequest
): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> {
const createResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
async: true,
}),
});
const createResult = await createResponse.json().catch(() => ({}));
if (!createResponse.ok || !createResult?.success) {
throw new Error(createResult?.error || "创建生图任务失败");
}
if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) {
return { images: createResult.data.images };
}
const taskId = String(createResult?.taskId || "").trim();
if (!taskId) {
throw new Error("创建生图任务失败:未返回任务ID");
}
const deadline = Date.now() + CANVAS_IMAGE_TASK_TIMEOUT_MS;
let lastError = "";
while (Date.now() < deadline) {
await sleep(CANVAS_IMAGE_TASK_POLL_INTERVAL_MS);
const statusResponse = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`,
{ cache: "no-store" }
);
const statusResult = await statusResponse.json().catch(() => ({}));
if (!statusResponse.ok || !statusResult?.success) {
lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`;
continue;
}
const task = statusResult?.task;
const status = String(task?.status || "").trim();
if (status === "succeeded") {
const result = task?.result || {};
const images = Array.isArray(result?.images) ? result.images : [];
if (images.length === 0) {
throw new Error("任务完成但未返回图片");
}
return { images };
}
if (status === "failed") {
throw new Error(String(task?.error || "图片生成失败"));
}
}
throw new Error(lastError || "图片生成超时,请稍后重试");
}
type CanvasAnalysisSection = {
before: string;
analysis: string;
after: string;
};
type CanvasTypewriterTextProps = {
text: string;
typingKey: string;
enabled: boolean;
className?: string;
onActiveChange?: (typingKey: string, active: boolean) => void;
onComplete?: (typingKey: string) => void;
};
function CanvasTypewriterText({
text,
typingKey,
enabled,
className,
onActiveChange,
onComplete,
}: CanvasTypewriterTextProps) {
const content = String(text || "");
const [displayText, setDisplayText] = useState(enabled ? "" : content);
const activeRef = useRef(false);
useEffect(() => {
if (!enabled || !content) {
setDisplayText(content);
if (activeRef.current) {
activeRef.current = false;
onActiveChange?.(typingKey, false);
}
return;
}
let cursor = 0;
activeRef.current = true;
onActiveChange?.(typingKey, true);
setDisplayText("");
const timer = window.setInterval(() => {
cursor += 1;
if (cursor >= content.length) {
setDisplayText(content);
window.clearInterval(timer);
if (activeRef.current) {
activeRef.current = false;
onActiveChange?.(typingKey, false);
}
onComplete?.(typingKey);
return;
}
setDisplayText(content.slice(0, cursor));
}, 16);
return () => {
window.clearInterval(timer);
if (activeRef.current) {
activeRef.current = false;
onActiveChange?.(typingKey, false);
}
};
}, [content, enabled, onActiveChange, onComplete, typingKey]);
return <div className={className}>{displayText}</div>;
}
function normalizeCanvasAgentText(value: string): string {
return String(value || "")
.replace(/\r\n/g, "\n")
.replace(/^#{1,6}\s*/gm, "")
.replace(/^\*\*(.+)\*\*$/gm, "$1")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function normalizeCanvasSectionTitle(line: string): string {
let normalized = line.trim();
if (!normalized) return "";
normalized = normalized
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\[(.+)\]$/, "$1")
.replace(/^【(.+)】$/, "$1")
.replace(/[::]+$/, "")
.trim();
return normalized;
}
function shouldSplitCanvasAnalysisAfterLine(line: string): boolean {
const normalized = String(line || "")
.replace(/^[-*]\s*/, "")
.trim();
if (!normalized) return false;
if (
/^(现在我将|接下来|下一步|我将|现在开始|下面我|随后|Then\b|Next\b|Now\b)/i.test(
normalized
)
) {
return true;
}
// 模型徽标行(例如:◈ Nano Banana Pro)
return /^[◈◇◆🔹⬡✦•·]\s*[^\n]+$/i.test(normalized);
}
function extractCanvasImageAnalysisSection(content: string): CanvasAnalysisSection | null {
const lines = String(content || "").split(/\r?\n/);
let startIndex = -1;
let inlineAnalysis = "";
for (let i = 0; i < lines.length; i += 1) {
const title = normalizeCanvasSectionTitle(lines[i]);
if (!title || !title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
startIndex = i;
const headingText = lines[i]
.trim()
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\[(.+)\]$/, "$1")
.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) {
if (shouldSplitCanvasAnalysisAfterLine(lines[i])) {
endIndex = i;
break;
}
const title = normalizeCanvasSectionTitle(lines[i]);
if (!title || title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
if (CANVAS_AGENT_SECTION_TITLES.has(title)) {
endIndex = i;
break;
}
}
const analysisLines = lines.slice(startIndex + 1, endIndex);
if (inlineAnalysis) {
analysisLines.unshift(inlineAnalysis);
}
const analysis = normalizeCanvasAgentText(analysisLines.join("\n"));
if (!analysis) return null;
return {
before: normalizeCanvasAgentText(lines.slice(0, startIndex).join("\n")),
analysis,
after: normalizeCanvasAgentText(lines.slice(endIndex).join("\n")),
};
}
function normalizeVideoParamsForProvider(
params: VideoGenerationParams | null | undefined,
providerType?: string,
capability?: VideoToolCapabilityContract | null
): VideoGenerationParams {
if (capability) {
const modeOptions = capability.modeOptions || [];
const ratioOptions = capability.ratioOptions || [];
const durationOptions = capability.durationOptions || [];
const resolutionOptions = capability.resolutionOptions || [];
const modeSet = new Set(modeOptions.map((item) => item.mode));
const ratioSet = new Set(ratioOptions);
const durationSet = new Set(durationOptions);
const resolutionSet = new Set(resolutionOptions);
const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video";
const fallbackRatio = capability.defaults?.ratio || ratioOptions[0] || "16:9";
const fallbackDuration = capability.defaults?.duration || durationOptions[0] || 5;
const fallbackResolution = capability.defaults?.resolution || resolutionOptions[0] || "720p";
const merged = {
mode: fallbackMode,
resolution: fallbackResolution as VideoGenerationParams["resolution"],
ratio: fallbackRatio as VideoGenerationParams["ratio"],
duration: Number(fallbackDuration) || 5,
generateAudio: capability.defaults?.generateAudio ?? false,
watermark: false,
cameraFixed: false,
cameraMove: "none" as VideoGenerationParams["cameraMove"],
fps: capability.defaults?.fps || 24,
...(params || {}),
} as VideoGenerationParams;
if (!modeSet.has(merged.mode)) {
merged.mode = fallbackMode as VideoGenerationParams["mode"];
}
if (!ratioSet.has(merged.ratio)) {
merged.ratio = fallbackRatio as VideoGenerationParams["ratio"];
}
if (!durationSet.has(Number(merged.duration))) {
merged.duration = Number(fallbackDuration) || 5;
} else {
merged.duration = Number(merged.duration) || Number(fallbackDuration) || 5;
}
if (resolutionOptions.length > 0 && !resolutionSet.has(merged.resolution)) {
merged.resolution = fallbackResolution as VideoGenerationParams["resolution"];
}
if (!capability.ui.showGenerateAudio) {
merged.generateAudio = false;
}
if (!capability.ui.showFps) {
merged.fps = capability.defaults?.fps || 24;
}
if (!capability.ui.showCameraMove) {
merged.cameraMove = "none";
merged.cameraFixed = false;
}
if (!capability.ui.showReturnLastFrame) {
merged.returnLastFrame = false;
}
if (!capability.ui.showSeed) {
merged.seed = undefined;
}
return merged;
}
const isFalVideo = providerType === "fal-video";
const isRuxaVideo = providerType === "ruxa-video";
const base: VideoGenerationParams = {
mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video",
resolution: "720p",
ratio: "16:9",
duration: 5,
generateAudio: true,
watermark: false,
cameraFixed: false,
cameraMove: "none",
fps: 24,
};
const merged = { ...base, ...(params || {}) };
if (isFalVideo) {
if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") {
merged.mode = "first-frame";
}
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5)));
merged.generateAudio = merged.generateAudio ?? true;
return merged;
}
if (isRuxaVideo) {
merged.mode = "first-frame";
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = merged.duration >= 8 ? 10 : 5;
merged.generateAudio = false;
merged.cameraMove = "none";
merged.cameraFixed = false;
merged.fps = 24;
return merged;
}
if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) {
merged.mode = "text-to-video";
}
if (!["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = merged.duration >= 8 ? 10 : 5;
return merged;
}
function getVideoImageLimit(
params: VideoGenerationParams | null | undefined,
providerType?: string,
capability?: VideoToolCapabilityContract | null
): { min: number; max: number } {
if (!params) {
return { min: 0, max: 0 };
}
if (capability?.modeOptions?.length) {
const modeOption = capability.modeOptions.find((item) => item.mode === params.mode);
if (modeOption) {
return { min: modeOption.imageMin, max: modeOption.imageMax };
}
}
const isFalVideo = providerType === "fal-video";
const isRuxaVideo = providerType === "ruxa-video";
if (isRuxaVideo) {
return { min: 1, max: 1 };
}
if (isFalVideo) {
if (params.mode === "first-last-frame") {
return { min: 2, max: 2 };
}
return { min: 1, max: 1 };
}
switch (params.mode) {
case "text-to-video":
return { min: 0, max: 0 };
case "first-last-frame":
return { min: 2, max: 2 };
case "first-frame":
return { min: 1, max: 1 };
case "reference-images":
return { min: 1, max: 4 };
default:
return { min: 0, max: 0 };
}
}
function extractUserTextPrompt(message: ChatMessage): string {
const parts = (message as any)?.parts || [];
const textFromParts = parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) =>
part.text
.replace(
/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g,
""
)
.trim()
)
.filter(Boolean)
.join("\n")
.trim();
if (textFromParts) return textFromParts;
const content = (message as any)?.content;
return typeof content === "string" ? content.trim() : "";
}
function findNearestUserPrompt(messages: ChatMessage[], assistantIndex: number): string {
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
const candidate = messages[i];
if (candidate?.role !== "user") continue;
const prompt = extractUserTextPrompt(candidate);
if (prompt) return prompt;
}
return "";
}
const GENERIC_CANVAS_PROMPT_SET = new Set([
"对话生成",
"图片生成",
"AI生成",
"智能生成",
"自动生成",
]);
function isGenericCanvasPrompt(prompt: unknown): boolean {
if (typeof prompt !== "string") return true;
const normalized = prompt.trim();
if (!normalized) return true;
return GENERIC_CANVAS_PROMPT_SET.has(normalized);
}
// 🆕 拾取物品的附件类型扩展
interface PickedObjectAttachment extends Attachment {
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
boundingBox: { x: number; y: number; width: number; height: number };
};
}
type MessageVote = {
chatId: string;
messageId: string;
isUpvoted: boolean;
};
interface CanvasChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
type DetailBriefForm = {
productSpu: string;
demandType: string;
productName: string;
targetAudience: string;
mainUser: string;
usageScenario: string;
triggerMoment: string;
coreNeed: string;
riskConcern: string;
expectedResult: string;
coreSellingPoint: string;
positioningStatement: string;
userVoice: string;
competitorReference: string;
priceBand: string;
brandTone: string;
bannedWords: string;
};
type DetailSlotConfig = {
key: string;
label: string;
role: "product" | "model" | "bundle";
};
type DetailSectionKey = "required" | "strategy" | "assets" | "archives";
type DetailGenerationMode = "classic" | "agent_layout";
type DetailArchive = {
id: string;
title: string;
prompt: string;
generationMode: DetailGenerationMode;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
createdAt: number;
updatedAt?: number;
summary?: string;
imageCount?: number;
coverUrl?: string;
};
type DetailPlanResponse = {
generationMode?: DetailGenerationMode;
summary?: string;
prompts?: string[];
screenSize?: {
width: number;
height: number;
};
globalStyle?: {
designLanguage?: string;
typography?: {
fontFamily?: string;
title?: string;
subtitle?: string;
body?: string;
};
layout?: {
grid?: string;
safeArea?: string;
rhythm?: string;
};
palette?: string;
};
screens?: Array<{
index: number;
screenType?: "A" | "B" | "C";
objective?: string;
title?: string;
subtitle?: string;
copy?: string;
layoutNote?: string;
useModel?: boolean;
modelReason?: string;
bundleShowcase?: boolean;
bundleReason?: string;
visualPrompt?: string;
fabe?: {
b?: string;
f?: string;
a?: string;
e?: string;
};
}>;
skillFlow?: {
name?: string;
version?: string;
steps?: string[];
};
qualityChecks?: {
passed?: boolean;
issues?: string[];
};
preflight?: {
passed?: boolean;
designDirectorScore?: number;
marketingDirectorScore?: number;
issues?: string[];
rewriteApplied?: boolean;
};
model?: {
id: string;
displayName: string;
providerType: string;
};
};
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
const DETAIL_TARGET_WIDTH = 790;
const DETAIL_TARGET_HEIGHT = 1300;
const DETAIL_REVIEW_COUNT = 3;
const DETAIL_REVIEW_REGEN_COUNT = 2;
const DETAIL_REVIEW_IMAGE_COUNT = 3;
const DETAIL_REVIEW_IMAGE_WIDTH = 420;
const DETAIL_REVIEW_IMAGE_HEIGHT = 420;
const DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS = 1;
const DETAIL_TYPOGRAPHY_LOCK = {
title: "主标题 64px / 700 / 行高1.15,建议单行最多2行,不得出现“第X屏”",
subtitle: "副标题 34px / 600 / 行高1.25,位于主标题下方16px",
body: "正文 24px / 500 / 行高1.45,段间距10px,每块最多4行",
safeArea: "文字安全区固定 y=64~360,产品主体避免遮挡标题区",
rhythm: "左右边距56px,模块间距24px,全套页面留白节奏一致",
} as const;
const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [
{ key: "product-1", label: "产品图1", role: "product" },
{ key: "product-2", label: "产品图2", role: "product" },
{ key: "product-3", label: "产品图3", role: "product" },
{ key: "product-4", label: "产品图4", role: "product" },
{ key: "product-5", label: "产品图5", role: "product" },
{ key: "model-1", label: "模特图", role: "model" },
{ key: "bundle-1", label: "搭配图1", role: "bundle" },
{ key: "bundle-2", label: "搭配图2", role: "bundle" },
{ key: "bundle-3", label: "搭配图3", role: "bundle" },
];
const DETAIL_PRODUCT_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === "product" ? index : -1
).filter((index) => index >= 0);
const DETAIL_MODEL_SLOT_INDEX = DETAIL_SLOT_CONFIGS.findIndex((slot) => slot.role === "model");
const DETAIL_BUNDLE_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === "bundle" ? index : -1
).filter((index) => index >= 0);
const DETAIL_FIELD_EXAMPLES: Record<keyof DetailBriefForm, string> = {
productSpu: "示例:SPU-889901",
demandType: "示例:清洁除尘 / 户外通勤 / 礼赠",
productName: "示例:Buyi-AI 无线手持吸尘器",
targetAudience: "示例:25-40岁养宠家庭、宝妈",
mainUser: "示例:家庭主理人、租房上班族",
usageScenario: "示例:下班回家10分钟快速清洁",
triggerMoment: "示例:宠物掉毛高峰、饭后碎屑清理",
coreNeed: "示例:省时省力清除毛发与灰尘",
riskConcern: "示例:担心吸力不足、续航短、噪音大",
expectedResult: "示例:3分钟清洁桌面,地面无明显毛发",
coreSellingPoint: "示例:18000Pa吸力、0.9kg轻量、一键倒尘",
positioningStatement: "示例:让日常清洁更快更轻松",
userVoice: "示例:家里有猫掉毛太多,希望安静一点不吵宝宝",
competitorReference: "示例:对标戴森入门款,突出轻便高性价比",
priceBand: "示例:199-299元",
brandTone: "示例:专业、可靠、亲和",
bannedWords: "示例:最强、第一、100%治愈",
};
const DETAIL_GENERATION_MODE_OPTIONS: Array<{
value: DetailGenerationMode;
label: string;
description: string;
}> = [
{
value: "classic",
label: "传统生图",
description: "整页直接生图,速度更快",
},
{
value: "agent_layout",
label: "AGENT排版",
description: "生图只出画面,文字层可编辑",
},
];
const SESSION_TITLE_MAX_LEN = 18;
function toRawCanvasChatId(value: string): string {
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
}
function getSessionStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
return `canvas-chat-sessions:${scope}:${canvasId}`;
}
function getSessionRootStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
return `canvas-chat-root:${scope}:${canvasId}`;
}
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "";
const prompt = extractUserTextPrompt(firstUserMessage).replace(/\s+/g, " ").trim();
if (!prompt) return "";
return prompt.length > SESSION_TITLE_MAX_LEN
? `${prompt.slice(0, SESSION_TITLE_MAX_LEN)}...`
: prompt;
}
function extractDetailFrameCount(text: string): number {
const normalized = text.replace(/\s+/g, "");
const matched = normalized.match(/(\d{1,2})(?:张|屏|页)/);
if (!matched) return 14;
const value = Number(matched[1]);
if (!Number.isFinite(value)) return 14;
return Math.min(20, Math.max(5, Math.round(value)));
}
type DetailReviewPlan = {
index: number;
title: string;
reviewText: string;
imagePrompt: string;
};
type DetailReviewGroup = {
reviewText: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
};
type DetailScreenReviewResult = {
pass: boolean;
score?: number;
issues?: string[];
layoutFixes?: string[];
revisedPrompt?: string;
model?: {
id?: string;
displayName?: string;
providerType?: string;
} | null;
};
function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function extractFirstNumber(value: string, fallback: number): number {
const matched = String(value || "").match(/(\d{1,3})/);
if (!matched) return fallback;
const parsed = Number(matched[1]);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
function extractFontWeight(value: string, fallback = "600"): string {
const matched = String(value || "").match(/(?:\/|weight[::]?\s*)(\d{3})/i);
if (!matched) return fallback;
return matched[1] || fallback;
}
function splitDetailKeywords(value: string, limit = 3): string[] {
const raw = String(value || "")
.split(/[\n,,、;;|]/)
.map((item) => item.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (raw.length > 0) return raw.slice(0, limit);
return [];
}
function buildDetailReviewPlans(params: {
brief: DetailBriefForm;
summary?: string;
language: "zh" | "en";
count?: number;
}): DetailReviewPlan[] {
const count = Math.max(1, Math.min(5, Math.round(params.count || DETAIL_REVIEW_COUNT)));
const audience = params.brief.targetAudience || (params.language === "en" ? "target users" : "目标用户");
const scenario = params.brief.usageScenario || (params.language === "en" ? "daily life scenario" : "日常场景");
const coreNeed = params.brief.coreNeed || (params.language === "en" ? "core need" : "核心需求");
const productName = params.brief.productName || (params.language === "en" ? "the product" : "该产品");
const expectedResult =
params.brief.expectedResult || (params.language === "en" ? "clear and reliable improvement" : "效果明显且稳定");
const riskConcern =
params.brief.riskConcern || (params.language === "en" ? "quality and usability concerns" : "担心效果不稳定");
const keywords = splitDetailKeywords(params.brief.coreSellingPoint, 3);
const firstKeyword = keywords[0] || (params.language === "en" ? "stable performance" : "稳定好用");
const secondKeyword = keywords[1] || (params.language === "en" ? "easy operation" : "上手简单");
const thirdKeyword = keywords[2] || (params.language === "en" ? "value for money" : "性价比高");
const summaryHint = String(params.summary || "").replace(/\s+/g, " ").trim();
if (params.language === "en") {
const templates = [
{
title: "Real Need Solved",
reviewText: `I am part of ${audience}. In ${scenario}, my biggest issue was ${coreNeed}. After using ${productName}, the result is ${expectedResult}. It feels ${firstKeyword} and I am willing to repurchase.`,
imagePrompt: `UGC buyer photo set for ${productName}, scenario: ${scenario}, highlight ${coreNeed} solved, realistic lifestyle composition, clear product details, natural lighting, authentic user-generated look`,
},
{
title: "From Concern to Trust",
reviewText: `Before purchase I worried about ${riskConcern}. After using it for a while, ${productName} stays ${secondKeyword} and reliable. The overall user experience is much better than expected.`,
imagePrompt: `UGC buyer experience photo set for ${productName}, before-after feeling in ${scenario}, close-up and handheld usage moments, realistic texture, social proof style, commercial clarity`,
},
{
title: "Conversion Testimonial",
reviewText: `For users like ${audience}, ${productName} is a practical choice. It balances ${firstKeyword}, ${secondKeyword}, and ${thirdKeyword}. The final outcome is ${expectedResult}.`,
imagePrompt: `UGC testimonial photo set for ${productName}, mixed angles including scene shot, detail shot and usage shot, trust-driven ecommerce style, clean but real, high conversion mood`,
},
];
const fallback = templates[templates.length - 1]!;
return Array.from({ length: count }, (_, index) => {
const template = templates[index] || fallback;
const summaryText = summaryHint ? ` ${summaryHint}` : "";
return {
index: index + 1,
title: `${template.title} ${index + 1}`,
reviewText: `${template.reviewText}${summaryText}`.trim(),
imagePrompt: template.imagePrompt,
};
});
}
const templates = [
{
title: "真实需求反馈",
reviewText: `我属于${audience},在${scenario}里最头疼的是${coreNeed}。用了${productName}后,明显感受到${expectedResult},整体体验${firstKeyword},确实值得回购。`,
imagePrompt: `${productName}买家真实晒单,场景:${scenario},突出“${coreNeed}被解决”的使用结果,生活化拍摄,真实自然光,产品细节清晰,电商高转化氛围`,
},
{
title: "顾虑消除反馈",
reviewText: `下单前我最担心${riskConcern},实际使用后发现${productName}${secondKeyword},稳定性也不错。连续使用一段时间后效果依旧在线。`,
imagePrompt: `${productName}买家体验晒单,表达“从担心到放心”的转变,含近景细节、手持使用、场景实拍,真实用户视角,质感干净`,
},
{
title: "转化口碑反馈",
reviewText: `如果你也是${audience},并且在${scenario}下有${coreNeed},这款${productName}真的很合适。它兼顾了${firstKeyword}、${secondKeyword}和${thirdKeyword},结果就是${expectedResult}。`,
imagePrompt: `${productName}买家口碑晒单,三张图分别体现场景图、细节图、结果图,构图统一,真实可信,强化转化购买意愿`,
},
];
const fallback = templates[templates.length - 1]!;
return Array.from({ length: count }, (_, index) => {
const template = templates[index] || fallback;
const summaryText = summaryHint ? ` ${summaryHint}` : "";
return {
index: index + 1,
title: `${template.title}${index + 1}`,
reviewText: `${template.reviewText}${summaryText}`.trim(),
imagePrompt: template.imagePrompt,
};
});
}
function buildDetailResultMarkdown(params: {
generationMode?: DetailGenerationMode;
summary?: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
prompts: string[];
skillFlow?: {
name?: string;
version?: string;
steps?: string[];
};
qualityChecks?: {
passed?: boolean;
issues?: string[];
};
globalStyle?: {
designLanguage?: string;
typography?: {
fontFamily?: string;
title?: string;
subtitle?: string;
body?: string;
};
layout?: {
grid?: string;
safeArea?: string;
rhythm?: string;
};
palette?: string;
};
screens?: Array<{
screenType?: "A" | "B" | "C";
visualPrompt?: string;
title?: string;
subtitle?: string;
copy?: string;
layoutNote?: string;
useModel?: boolean;
modelReason?: string;
bundleShowcase?: boolean;
bundleReason?: string;
fabe?: {
b?: string;
f?: string;
a?: string;
e?: string;
};
}>;
reviewGroups?: DetailReviewGroup[];
}): string {
const lines: string[] = [];
lines.push("### AI详情生成完成");
if (params.generationMode) {
lines.push(
`生成路线:${params.generationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页生图)"}`
);
}
if (params.summary) {
lines.push(params.summary);
}
if (params.skillFlow?.steps?.length) {
lines.push(
`技能流程:${params.skillFlow.name || "AI详情生成"} ${params.skillFlow.version ? `(v${params.skillFlow.version})` : ""}`
);
params.skillFlow.steps.forEach((step, index) => {
lines.push(`${index + 1}. ${step}`);
});
}
if (params.qualityChecks) {
if (params.qualityChecks.passed) {
lines.push("质检结果:通过");
} else if (params.qualityChecks.issues?.length) {
lines.push("质检结果:未通过,已使用回退方案");
params.qualityChecks.issues.forEach((issue) => lines.push(`- ${issue}`));
}
}
lines.push(`共生成 ${params.images.length} 张详情图。`);
if (params.globalStyle) {
const typography = params.globalStyle.typography;
lines.push(
`全局风格:${params.globalStyle.designLanguage || "统一电商详情风格"}`
);
lines.push(
`字体规范:${typography?.fontFamily || "统一字体"} / ${typography?.title || "标题规范一致"} / ${typography?.subtitle || "副标题规范一致"}`
);
}
lines.push("");
params.images.forEach((image, index) => {
const screen = params.screens?.[index];
const prompt = params.prompts[index] || screen?.visualPrompt || "";
lines.push(`#### 分镜 ${index + 1}`);
if (screen?.screenType) {
lines.push(`分镜类型:${screen.screenType}`);
}
if (screen?.title) {
lines.push(`标题:${screen.title}`);
}
if (screen?.subtitle) {
lines.push(`副标题:${screen.subtitle}`);
}
if (screen?.copy) {
lines.push(`文案:${screen.copy}`);
}
if (screen?.layoutNote) {
lines.push(`排版:${screen.layoutNote}`);
}
if (screen?.useModel !== undefined) {
lines.push(screen.useModel ? "人物:使用固定模特图" : "人物:不使用模特图");
}
if (screen?.modelReason) {
lines.push(`人物策略:${screen.modelReason}`);
}
if (screen?.bundleShowcase !== undefined) {
lines.push(screen.bundleShowcase ? "搭配展示:是" : "搭配展示:否");
}
if (screen?.bundleReason) {
lines.push(`搭配策略:${screen.bundleReason}`);
}
if (screen?.fabe) {
const fabeParts = [
screen.fabe.b ? `B ${screen.fabe.b}` : "",
screen.fabe.f ? `F ${screen.fabe.f}` : "",
screen.fabe.a ? `A ${screen.fabe.a}` : "",
screen.fabe.e ? `E ${screen.fabe.e}` : "",
].filter(Boolean);
if (fabeParts.length > 0) {
lines.push(`FABE:${fabeParts.join(" | ")}`);
}
}
if (prompt) {
lines.push(prompt);
}
lines.push(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
lines.push("");
});
if (params.reviewGroups?.length) {
lines.push("### 买家评价与晒图");
params.reviewGroups.forEach((group, index) => {
lines.push(`评价 ${index + 1}:${group.reviewText}`);
group.images.forEach((image) => {
lines.push(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
});
lines.push("");
});
}
return lines.join("\n").trim();
}
function buildDetailReviewAppendMarkdown(params: {
groups: DetailReviewGroup[];
failedCount?: number;
}): string {
const lines: string[] = [];
lines.push("### 买家评价与晒图已追加");
lines.push(`本次新增 ${params.groups.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张晒图。`);
if (params.failedCount && params.failedCount > 0) {
lines.push(`失败 ${params.failedCount} 条,已跳过。`);
}
lines.push("");
params.groups.forEach((group, index) => {
lines.push(`评价 ${index + 1}:${group.reviewText}`);
group.images.forEach((image) => {
lines.push(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
});
lines.push("");
});
return lines.join("\n").trim();
}
export function CanvasChatPanel({
chatId,
initialMessages = [],
onClose,
onOpenFileList,
selectedAgentPresetId,
chatScope = "canvas",
}: CanvasChatPanelProps) {
const { data: session } = useSession();
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false);
const [detailGenerationMode, setDetailGenerationMode] = useState<DetailGenerationMode>("classic");
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
const [detailSections, setDetailSections] = useState<Record<DetailSectionKey, boolean>>({
required: true,
strategy: false,
assets: true,
archives: true,
});
const [isDetailWorkbenchCollapsed, setIsDetailWorkbenchCollapsed] = useState(true);
const [detailProgress, setDetailProgress] = useState<{
status: "idle" | "running" | "done" | "error";
stage: string;
current: number;
total: number;
}>({
status: "idle",
stage: "",
current: 0,
total: 0,
});
const [detailSlots, setDetailSlots] = useState<Array<PickedObjectAttachment | null>>(
() => DETAIL_SLOT_CONFIGS.map(() => null)
);
const [detailSlotUploading, setDetailSlotUploading] = useState<number | null>(null);
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState<string | null>(null);
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
productSpu: "",
demandType: "",
productName: "",
targetAudience: "",
mainUser: "",
usageScenario: "",
triggerMoment: "",
coreNeed: "",
riskConcern: "",
expectedResult: "",
coreSellingPoint: "",
positioningStatement: "",
userVoice: "",
competitorReference: "",
priceBand: "",
brandTone: "",
bannedWords: "",
});
const [detailModeMessageStartIndex, setDetailModeMessageStartIndex] = useState(0);
const canvasId = useMemo(() => toRawCanvasChatId(chatId), [chatId]);
const [activeChatId, setActiveChatId] = useState<string>(chatId);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null);
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0);
const hasInitializedChatStateRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null);
// 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string;
alt?: string;
} | null>(null);
// 🆕 图片参数状态
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
const [agentSlotUploading, setAgentSlotUploading] = useState<number | null>(null);
const [lastImageModelId, setLastImageModelId] = useState<string | null>(null);
const [lastVideoModelId, setLastVideoModelId] = useState<string | null>(null);
const [lastConversationModelId, setLastConversationModelId] = useState<string | null>(null);
const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false);
const [analysisCollapseState, setAnalysisCollapseState] = useState<Record<string, boolean>>({});
const [typedAssistantSections, setTypedAssistantSections] = useState<Record<string, boolean>>({});
const [activeTypingSections, setActiveTypingSections] = useState<Record<string, boolean>>({});
const [insufficientPointsState, setInsufficientPointsState] = useState<{
balance: number;
expectedCost: number;
unitCost: number;
imageCount: number;
} | null>(null);
const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] =
useState<Record<string, ImageGenerationStatusStreamPayload | null>>({});
const streamImageGenerationState = useMemo(
() => streamImageGenerationStateByChatId[activeChatId] || null,
[activeChatId, streamImageGenerationStateByChatId]
);
const imageParamsRef = useRef(imageParams);
const videoParamsRef = useRef(videoParams);
const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
const imageAutoTitleCacheRef = useRef<Map<string, string>>(new Map());
const sendGuardRef = useRef<Record<string, {
inFlight: boolean;
signature: string;
timestamp: number;
}>>({});
const storyboardNodeMapRef = useRef<Record<string, Record<number, string>>>({});
const getSendGuard = useCallback((chatSessionId: string) => {
return (
sendGuardRef.current[chatSessionId] || {
inFlight: false,
signature: "",
timestamp: 0,
}
);
}, []);
const setSendGuard = useCallback(
(
chatSessionId: string,
next: {
inFlight: boolean;
signature: string;
timestamp: number;
}
) => {
sendGuardRef.current[chatSessionId] = next;
},
[]
);
const releaseSendGuard = useCallback(
(chatSessionId: string) => {
sendGuardRef.current[chatSessionId] = {
...getSendGuard(chatSessionId),
inFlight: false,
};
},
[getSendGuard]
);
const setStreamImageGenerationStateForChat = useCallback(
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
setStreamImageGenerationStateByChatId((prev) => ({
...prev,
[chatSessionId]: next,
}));
},
[]
);
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
setStreamImageGenerationStateByChatId((prev) => {
if (!(chatSessionId in prev)) return prev;
const next = { ...prev };
delete next[chatSessionId];
return next;
});
}, []);
const shouldBlockDuplicateSend = useCallback((chatSessionId: string, signature: string) => {
const now = Date.now();
const previous = getSendGuard(chatSessionId);
return (
previous.signature === signature &&
now - previous.timestamp < 4500
);
}, [getSendGuard]);
const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => {
setActiveTypingSections((prev) => {
if (active) {
if (prev[typingKey]) return prev;
return { ...prev, [typingKey]: true };
}
if (!prev[typingKey]) return prev;
const next = { ...prev };
delete next[typingKey];
return next;
});
}, []);
const handleTypewriterComplete = useCallback((typingKey: string) => {
setTypedAssistantSections((prev) =>
prev[typingKey] ? prev : { ...prev, [typingKey]: true }
);
}, []);
const autoNameGeneratedImageNode = useCallback(
async (nodeId: string, imageUrl: string) => {
if (!nodeId || !imageUrl) return;
const normalizeAutoTitle = (rawTitle: string) => {
const compact = String(rawTitle || "").replace(/\s+/g, "").trim();
if (!compact || compact === "新画布" || compact === "未命名画布" || compact.includes("画布")) {
return "图片";
}
return compact.slice(0, 12);
};
const applyNodeTitle = (title: string) => {
const storeState = useCanvasStore.getState();
const currentNode = storeState.getNode(nodeId);
if (!currentNode) return;
const currentData = ((currentNode.data as any) || {}) as Record<string, any>;
storeState.updateNode(nodeId, {
data: {
...currentData,
title,
metadata: {
...(currentData.metadata || {}),
autoTitle: title,
},
},
} as any);
};
const cachedTitle = imageAutoTitleCacheRef.current.get(imageUrl);
if (cachedTitle) {
applyNodeTitle(cachedTitle);
return;
}
try {
const response = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
});
const data = await response.json().catch(() => ({}));
const title = normalizeAutoTitle(String(data?.title || ""));
imageAutoTitleCacheRef.current.set(imageUrl, title);
applyNodeTitle(title);
} catch (error) {
console.warn('[CanvasChat] 自动命名图片失败:', error);
applyNodeTitle("图片");
}
},
[],
);
// 🆕 保存图片参数到 localStorage
const handleImageParamsChange = (params: ImageGenerationParams) => {
setImageParams(params);
imageParamsRef.current = params;
try {
localStorage.setItem("canvas-image-params", JSON.stringify(params));
} catch (e) {
console.error("Failed to save image params:", e);
}
};
// 加载图片参数
useEffect(() => {
try {
const saved = localStorage.getItem("canvas-image-params");
if (saved) {
const parsed = JSON.parse(saved);
setImageParams((prev) => {
const next = { ...prev, ...parsed };
imageParamsRef.current = next;
return next;
});
}
} catch (e) {
console.error("Failed to load image params:", e);
}
}, []);
const handleVideoParamsChange = (params: VideoGenerationParams) => {
setVideoParams(params);
videoParamsRef.current = params;
try {
localStorage.setItem("canvas-video-params", JSON.stringify(params));
} catch (e) {
console.error("Failed to save video params:", e);
}
};
useEffect(() => {
try {
const saved = localStorage.getItem("canvas-video-params");
if (saved) {
const parsed = JSON.parse(saved);
setVideoParams(parsed);
videoParamsRef.current = parsed;
}
} catch (e) {
console.error("Failed to load video params:", e);
}
}, []);
useEffect(() => {
if (!fullscreenPreview) return;
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreenPreview(null);
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [fullscreenPreview]);
useEffect(() => {
setTypedAssistantSections({});
setActiveTypingSections({});
setSelectedSkillTagId(null);
}, [activeChatId]);
useEffect(() => {
const now = Date.now();
let rootChatId = chatId;
if (chatScope === "agent") {
try {
const rootKey = getSessionRootStorageKey(canvasId, chatScope);
const savedRootChatId = localStorage.getItem(rootKey);
if (savedRootChatId && savedRootChatId.startsWith("canvas-")) {
rootChatId = savedRootChatId;
} else {
rootChatId = `canvas-${crypto.randomUUID()}`;
localStorage.setItem(rootKey, rootChatId);
}
} catch (error) {
console.error("[CanvasChat] Failed to initialize agent root chat id:", error);
rootChatId = `canvas-${crypto.randomUUID()}`;
}
}
const fallbackSession: CanvasChatSession = {
id: rootChatId,
title: "对话 1",
createdAt: now,
updatedAt: now,
};
try {
const key = getSessionStorageKey(canvasId, chatScope);
const saved = localStorage.getItem(key);
const parsed = saved ? (JSON.parse(saved) as CanvasChatSession[]) : [];
const sanitized = parsed
.filter((session) => session?.id)
.map((session) => ({
id: session.id,
title: (session.title || "").trim() || "新对话",
createdAt: Number(session.createdAt) || now,
updatedAt: Number(session.updatedAt) || now,
}));
const hasCurrent = sanitized.some((session) => session.id === rootChatId);
const nextSessions = hasCurrent
? sanitized
: [fallbackSession, ...sanitized];
const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt);
setChatSessions(sorted);
setActiveChatId(rootChatId);
localStorage.setItem(key, JSON.stringify(sorted));
} catch (error) {
console.error("[CanvasChat] Failed to load chat sessions:", error);
setChatSessions([fallbackSession]);
setActiveChatId(rootChatId);
}
}, [canvasId, chatId, chatScope]);
useEffect(() => {
if (!isSessionMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!sessionMenuRef.current?.contains(event.target as Node)) {
setIsSessionMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isSessionMenuOpen]);
useEffect(() => {
try {
localStorage.setItem(
getSessionStorageKey(canvasId, chatScope),
JSON.stringify(chatSessions)
);
} catch (error) {
console.error("[CanvasChat] Failed to save chat sessions:", error);
}
}, [canvasId, chatScope, chatSessions]);
// Canvas store
const addNode = useCanvasStore((state) => state.addNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const nodes = useCanvasStore((state) => state.nodes);
const projectTitle = useCanvasStore((state) => state.projectTitle);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const pendingChatAttachments = useCanvasStore((state) => state.pendingChatAttachments);
const clearPendingChatAttachments = useCanvasStore((state) => state.clearPendingChatAttachments);
// 🆕 物品拾取
const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects);
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
const rawChatId = toRawCanvasChatId(activeChatId);
const voteSWRKey = rawChatId ? `/api/vote?chatId=${rawChatId}` : null;
const { mutate } = useSWRConfig();
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
return;
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
// 从 API 加载历史消息
const { data: historyData } = useSWR<{
messages: ChatMessage[];
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
const { data: messageVotes } = useSWR<MessageVote[]>(
voteSWRKey,
fetcher
);
// 获取选中的图片节点
const selectedImageNodes = nodes.filter(
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
);
const handleAssistantVote = useCallback(
async (messageId: string, type: "up" | "down") => {
if (!rawChatId) return;
setVotingMessageId(messageId);
try {
const response = await fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId: rawChatId,
messageId,
type,
}),
});
if (!response.ok) {
throw new Error("Vote failed");
}
mutate<MessageVote[]>(
voteSWRKey,
(currentVotes) => {
const baseVotes = Array.isArray(currentVotes) ? currentVotes : [];
const filteredVotes = baseVotes.filter(
(vote) => vote.messageId !== messageId
);
return [
...filteredVotes,
{
chatId: rawChatId,
messageId,
isUpvoted: type === "up",
},
];
},
{ revalidate: false }
);
toast.success(type === "up" ? "已点赞" : "已点踩");
} catch (error) {
console.error("[CanvasChat] vote failed:", error);
toast.error(type === "up" ? "点赞失败,请稍后重试" : "点踩失败,请稍后重试");
} finally {
setVotingMessageId(null);
}
},
[mutate, rawChatId, voteSWRKey]
);
const getCanvasNodeAttachment = useCallback(
(node: any, fallbackName: string): PickedObjectAttachment | null => {
const src = (node?.data as any)?.src;
if (!src || typeof src !== "string") return null;
const nodeTitle =
String((node?.data as any)?.title || "").trim() ||
String((node?.data as any)?.prompt || "").trim() ||
fallbackName;
return {
url: src,
name: nodeTitle,
contentType: "image/jpeg",
assetId:
typeof (node?.data as any)?.assetId === "string" && (node?.data as any)?.assetId.trim()
? String((node?.data as any)?.assetId).trim()
: undefined,
};
},
[]
);
// 🆕 辅助函数:在 contenteditable 中插入图片标签
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
// 检查 DOM 中是否已存在该 URL 的标签(如果存在则不重复添加)
const existing = editableDiv.querySelector(`[data-attachment-url="${url}"]`);
if (existing) {
console.log('[CanvasChat] 标签已存在于 DOM 中,跳过:', url.substring(0, 50));
return;
}
const tagSpan = document.createElement('span');
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
tagSpan.innerHTML = `
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
<span class="text-xs text-foreground/80">${shortName}</span>
`;
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
const spaceNode = document.createTextNode("\u200b");
range.insertNode(spaceNode);
range.insertNode(tagSpan);
range.setStartAfter(spaceNode);
range.setEndAfter(spaceNode);
selection.removeAllRanges();
selection.addRange(range);
} else {
editableDiv.appendChild(tagSpan);
editableDiv.appendChild(document.createTextNode("\u200b"));
}
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
}, []);
// 🆕 辅助函数:在 contenteditable 中插入物品标签(带图片缩略图,样式与普通图片标签一致)
const insertPickedObjectTagInEditor = useCallback((
url: string,
objectName: string,
objectPosition: string,
index: number
) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv || !editableDiv.isContentEditable) return;
// 检查 DOM 中是否已存在该物品的标签
const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]');
for (const tag of existingTags) {
if (tag.getAttribute('data-attachment-url') === url &&
tag.getAttribute('data-object-name') === objectName) {
console.log('[CanvasChat] 物品标签已存在,跳过');
return;
}
}
const tagSpan = document.createElement('span');
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.setAttribute('data-picked-object', 'true');
tagSpan.setAttribute('data-object-name', objectName);
tagSpan.setAttribute('data-object-position', objectPosition);
// 样式与普通图片标签一致
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
// 使用图片缩略图,不使用 emoji 或图标
tagSpan.innerHTML = `
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
<span class="text-xs text-foreground/80">${objectName}</span>
<span class="text-xs text-foreground/50">·</span>
<span class="text-xs text-foreground/70">${objectPosition}</span>
`;
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
const range = selection.getRangeAt(0);
range.deleteContents();
const spaceNode = document.createTextNode("\u200b");
range.insertNode(spaceNode);
range.insertNode(tagSpan);
range.setStartAfter(spaceNode);
range.setEndAfter(spaceNode);
selection.removeAllRanges();
selection.addRange(range);
} else {
editableDiv.appendChild(tagSpan);
editableDiv.appendChild(document.createTextNode("\u200b"));
}
console.log('[CanvasChat] 已插入物品标签:', objectName, objectPosition);
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
const storageKey = getCanvasLaunchStorageKey(canvasId);
const raw = sessionStorage.getItem(storageKey);
if (!raw) return;
sessionStorage.removeItem(storageKey);
const parsed = JSON.parse(raw) as CanvasLaunchPayload;
const normalizedAttachments = Array.isArray(parsed.attachments)
? parsed.attachments
.filter((item) => item?.url)
.map((item) => ({
url: String(item.url),
name: String(item.name || "参考图"),
contentType: String(item.contentType || "image/jpeg"),
assetId:
typeof item.assetId === "string" && item.assetId.trim()
? item.assetId.trim()
: undefined,
}))
: [];
const normalizedPrompt = String(parsed.prompt || "").trim();
if (!normalizedPrompt && normalizedAttachments.length === 0) {
return;
}
setAttachments(normalizedAttachments as PickedObjectAttachment[]);
setInput(normalizedPrompt);
if (parsed.selectedModelId) {
setSelectedModelId(String(parsed.selectedModelId));
}
if (parsed.imageParams && typeof parsed.imageParams === "object") {
setImageParams((prev) => {
const next = {
...prev,
...parsed.imageParams,
};
imageParamsRef.current = next;
return next;
});
}
if (!parsed.autoSend) {
setTimeout(() => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
if (normalizedPrompt) {
editableDiv.appendChild(document.createTextNode(normalizedPrompt));
const selection = window.getSelection();
if (selection) {
const range = document.createRange();
range.selectNodeContents(editableDiv);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
normalizedAttachments.forEach((attachment, index) => {
insertImageTagInEditor(attachment.url, attachment.name, index);
});
}, 50);
}
if (parsed.autoSend) {
pendingLaunchRef.current = {
prompt: normalizedPrompt,
attachments: normalizedAttachments,
autoSend: true,
createdAt: parsed.createdAt,
selectedModelId: parsed.selectedModelId,
imageParams: parsed.imageParams,
};
setLaunchAutoSendTick((value) => value + 1);
}
normalizedAttachments.forEach(async (attachment, index) => {
const dimensions = await getImageDimensions(attachment.url);
addNode({
type: "image",
position: {
x: -260 + index * 80,
y: -120 + (index % 2) * 40,
},
size: {
width: dimensions.width,
height: dimensions.height,
},
data: {
src: attachment.url,
status: "completed",
title: attachment.name || `参考图 ${index + 1}`,
prompt: normalizedPrompt || "",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
});
} catch (error) {
console.error("[CanvasChat] Failed to restore launch payload:", error);
}
}, [canvasId, addNode, insertImageTagInEditor]);
// 🆕 监听 pendingPickedObjects,当用户确认拾取物品时添加标签
useEffect(() => {
if (pendingPickedObjects.length > 0) {
const currentAttachments = [...attachments];
pendingPickedObjects.forEach((picked, i) => {
// 检查是否已存在相同的物品
const exists = currentAttachments.some(a =>
a.url === picked.imageUrl &&
a.pickedObject?.objectName === picked.objectName
);
if (!exists) {
// 插入物品标签到编辑器
insertPickedObjectTagInEditor(
picked.imageUrl,
picked.objectName,
picked.objectPosition,
currentAttachments.length + i
);
// 添加到附件列表(带物品信息,原图片 URL 会发送给 AI 用于多图融合编辑)
currentAttachments.push({
url: picked.imageUrl, // 原图片 URL
name: `${picked.objectName}-${picked.objectPosition}`, // 物品名称-位置
contentType: 'image/jpeg',
pickedObject: {
objectName: picked.objectName,
objectPosition: picked.objectPosition,
clickX: picked.clickX,
clickY: picked.clickY,
boundingBox: picked.boundingBox,
},
});
}
});
setAttachments(currentAttachments);
// 聚焦到输入框
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
// 清空待添加列表
clearPendingPickedObjects();
}
}, [pendingPickedObjects, attachments, insertPickedObjectTagInEditor, clearPendingPickedObjects]);
// 🆕 标签悬停预览 - 使用全局事件监听
useEffect(() => {
let lastTagElement: HTMLElement | null = null;
const handleMouseMove = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const tagSpan = target.closest('[data-attachment-url]') as HTMLElement | null;
// 检查是否在预览弹窗内
const inPreview = target.closest('[data-tag-preview]');
if (inPreview) {
// 在预览弹窗内,保持显示
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
return;
}
if (tagSpan && tagSpan !== lastTagElement) {
lastTagElement = tagSpan;
// 清除之前的延迟隐藏
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
const url = tagSpan.getAttribute('data-attachment-url');
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
const objectName = tagSpan.getAttribute('data-object-name');
if (url) {
const rect = tagSpan.getBoundingClientRect();
// 从 attachments 中获取物品信息
const attachment = attachments.find(a =>
a.url === url &&
(isPickedObject ? a.pickedObject?.objectName === objectName : true)
);
// 获取聊天面板容器边界(向上查找最近的面板容器)
const panelContainer = tagSpan.closest('.flex.flex-col.h-full') as HTMLElement | null;
let containerBounds: TagPreviewInfo['containerBounds'] = undefined;
if (panelContainer) {
const panelRect = panelContainer.getBoundingClientRect();
containerBounds = {
left: panelRect.left,
right: panelRect.right,
top: panelRect.top,
bottom: panelRect.bottom,
};
}
setTagPreview({
url,
name: objectName || tagSpan.textContent || '图片',
x: rect.left + rect.width / 2,
y: rect.top,
containerBounds,
pickedObject: attachment?.pickedObject ? {
objectName: attachment.pickedObject.objectName,
objectPosition: attachment.pickedObject.objectPosition,
clickX: attachment.pickedObject.clickX,
clickY: attachment.pickedObject.clickY,
} : undefined,
});
}
} else if (!tagSpan && lastTagElement) {
// 离开标签
lastTagElement = null;
previewTimeoutRef.current = setTimeout(() => {
setTagPreview(null);
}, 200);
}
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
};
}, [attachments]);
// 获取模型列表
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
sortOrder?: number;
providerType: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
isMultimodal?: boolean;
capabilities?: any;
videoToolCapability?: VideoToolCapabilityContract | null;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
} | null;
}>;
agentModel?: {
id: string;
modelId?: string;
displayName: string;
avatarUrl?: string | null;
providerType: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
isMultimodal?: boolean;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
} | null;
} | null;
}>("/api/models", fetcher);
// 获取网站设置(包含广告横幅配置和画布设置)
const { data: siteSettings } = useSWR<{
bannerEnabled?: boolean;
bannerText?: string;
bannerLink?: string | null;
lovartComposerPrefix?: string | null;
lovartComposerPreviewTexts?: string[];
canvasEnabled?: boolean;
aiDetailGenerationEnabled?: boolean;
canvasDefaultModelId?: string | null;
canvasAutoModeDefaultModelId?: string | null;
canvasDetailDefaultModelId?: string | null;
}>("/api/site-settings", fetcher);
const { data: canvasSkillTagsData } = useSWR<{
enabled?: boolean;
title?: string;
tags?: CanvasSkillTag[];
}>(
"/api/canvas-skill-tags",
fetcher
);
const { data: membershipSummary } = useSWR<{
isHighVip?: boolean;
pointsSystemConfig?: {
enabled?: boolean;
blockOnInsufficient?: boolean;
};
summary?: {
currentCredits?: number;
};
} | null>("/api/user/membership-summary", async (url: string) => {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return null;
return response.json();
});
const { data: userImageStats } = useSWR<{ totalImages?: number } | null>(
"/api/user/image-stats",
async (url: string) => {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return null;
return response.json();
}
);
const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset }>(
selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null,
fetcher
);
const greetingUserName = useMemo(() => {
const userName = String(session?.user?.name || "").trim();
if (userName) return userName;
const email = String(session?.user?.email || "").trim();
if (email.includes("@")) {
return email.split("@")[0];
}
return "";
}, [session?.user?.email, session?.user?.name]);
const [canvasTypedPreviewText, setCanvasTypedPreviewText] = useState("");
const [canvasPreviewCursorVisible, setCanvasPreviewCursorVisible] = useState(true);
const [greetingPeriodText, setGreetingPeriodText] = useState("中午好!");
useEffect(() => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) {
setGreetingPeriodText("早上好!");
return;
}
if (hour >= 11 && hour < 18) {
setGreetingPeriodText("中午好!");
return;
}
if (hour >= 18) {
setGreetingPeriodText("晚上好!");
return;
}
setGreetingPeriodText("深夜好!");
}, []);
const canvasComposerPrefix = useMemo(
() =>
String(siteSettings?.lovartComposerPrefix || "").trim() ||
CANVAS_COMPOSER_PREFIX_DEFAULT,
[siteSettings?.lovartComposerPrefix]
);
const canvasComposerPreviewTexts = useMemo(() => {
const configured = (siteSettings?.lovartComposerPreviewTexts || [])
.map((item) => String(item || "").trim())
.filter(Boolean);
return configured.length > 0
? configured
: CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT;
}, [siteSettings?.lovartComposerPreviewTexts]);
const canvasComposerPreviewTails = useMemo(
() =>
canvasComposerPreviewTexts
.map((text) => normalizeComposerPreviewTail(text, canvasComposerPrefix))
.filter(Boolean),
[canvasComposerPrefix, canvasComposerPreviewTexts]
);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (canvasComposerPreviewTails.length === 0) {
setCanvasTypedPreviewText("");
return () => {
if (timer) clearTimeout(timer);
};
}
let textIndex = 0;
let charIndex = 0;
const typeForward = () => {
if (disposed) return;
const currentText = canvasComposerPreviewTails[textIndex] || "";
if (charIndex < currentText.length) {
charIndex += 1;
setCanvasTypedPreviewText(currentText.slice(0, charIndex));
timer = setTimeout(typeForward, 55);
return;
}
if (canvasComposerPreviewTails.length === 1) {
setCanvasTypedPreviewText(currentText);
return;
}
timer = setTimeout(typeBackward, 1200);
};
const typeBackward = () => {
if (disposed) return;
const currentText = canvasComposerPreviewTails[textIndex] || "";
if (charIndex > 0) {
charIndex -= 1;
setCanvasTypedPreviewText(currentText.slice(0, charIndex));
timer = setTimeout(typeBackward, 35);
return;
}
textIndex = (textIndex + 1) % canvasComposerPreviewTails.length;
timer = setTimeout(typeForward, 260);
};
setCanvasTypedPreviewText("");
timer = setTimeout(typeForward, 260);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
};
}, [canvasComposerPreviewTails]);
useEffect(() => {
const blinkTimer = setInterval(() => {
setCanvasPreviewCursorVisible((value) => !value);
}, 450);
return () => clearInterval(blinkTimer);
}, []);
const canvasDynamicPlaceholder = `${canvasComposerPrefix}${canvasTypedPreviewText}${
canvasPreviewCursorVisible ? " |" : ""
}`;
const totalGeneratedImages = Number(userImageStats?.totalImages ?? NaN);
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
const shouldShowCanvasBanner =
!isDetailGenerationMode &&
(siteSettings?.bannerEnabled ?? true) &&
Boolean(siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!");
const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false;
const canvasSkillTagsTitle =
String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
const canvasSkillTags = useMemo(
() => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []),
[canvasSkillTagsData?.tags, canvasSkillTagsEnabled]
);
const selectedSkillTag = useMemo(
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
[canvasSkillTags, selectedSkillTagId]
);
const selectedAgentPreset = useMemo(
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const SelectedSkillTagIcon = useMemo(
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
[selectedSkillTag?.iconName]
);
useEffect(() => {
if (!selectedSkillTagId) return;
if (!canvasSkillTags.some((tag) => tag.id === selectedSkillTagId)) {
setSelectedSkillTagId(null);
}
}, [canvasSkillTags, selectedSkillTagId]);
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]);
const visualModels = useMemo(() => {
const filtered =
modelsData?.models?.filter(
(model) =>
model.modelId !== "volcano-video-master" &&
(
model.supportsImageGen ||
model.isMultimodal ||
VIDEO_PROVIDER_TYPES.has(model.providerType)
)
) || [];
return [...filtered].sort((a, b) => {
const leftOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : 0;
const rightOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : 0;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(a.displayName || "").localeCompare(String(b.displayName || ""), "zh-Hans-CN");
});
}, [modelsData?.models]);
// Agent模式(自动路由)模型选项
const SMART_AUTO_MODEL = {
id: "auto",
modelId: "auto",
displayName: "Agent模式",
avatarUrl: modelsData?.agentModel?.avatarUrl || null,
sortOrder: -999,
providerType: "auto",
supportsImageGen: true,
isMultimodal: false,
videoToolCapability: null,
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
};
const multimodalAgentModel = useMemo(() => {
if (!modelsData?.agentModel?.id || !modelsData.agentModel.isMultimodal) {
return null;
}
return {
id: modelsData.agentModel.id,
modelId: modelsData.agentModel.modelId || modelsData.agentModel.id,
displayName: modelsData.agentModel.displayName,
avatarUrl: modelsData.agentModel.avatarUrl || null,
sortOrder: -998,
providerType: modelsData.agentModel.providerType,
supportsVision: modelsData.agentModel.supportsVision,
supportsImageGen: modelsData.agentModel.supportsImageGen,
isMultimodal: true,
videoToolCapability: null,
imageGenConfig: modelsData.agentModel.imageGenConfig || undefined,
};
}, [modelsData?.agentModel]);
// 所有可选模型(Agent模式 + 可视化模型)
const allModels = useMemo(() => {
const merged = [
SMART_AUTO_MODEL,
...(multimodalAgentModel ? [multimodalAgentModel] : []),
...visualModels,
];
const seen = new Set<string>();
return merged.filter((model) => {
if (!model?.id || seen.has(model.id)) {
return false;
}
seen.add(model.id);
return true;
});
}, [SMART_AUTO_MODEL, multimodalAgentModel, visualModels]);
const imageOnlyModels = useMemo(
() =>
visualModels.filter(
(model) =>
model.supportsImageGen &&
!model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
),
[visualModels]
);
const conversationOnlyModels = useMemo(
() =>
allModels.filter(
(model) =>
model.id !== "auto" &&
model.id !== modelsData?.agentModel?.id &&
model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
),
[allModels, modelsData?.agentModel?.id]
);
const videoOnlyModels = useMemo(
() => visualModels.filter((model) => VIDEO_PROVIDER_TYPES.has(model.providerType)),
[visualModels]
);
const currentModel = allModels.find((model) => model.id === selectedModelId);
const isVideoModel = Boolean(currentModel && VIDEO_PROVIDER_TYPES.has(currentModel.providerType));
const isConversationModel = Boolean(
currentModel &&
currentModel.id !== "auto" &&
currentModel.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(currentModel.providerType)
);
const isAgentModel =
!isDetailGenerationMode &&
(!selectedModelId || selectedModelId === "auto" || isConversationModel);
const agentSelectableModels = useMemo(
() => [SMART_AUTO_MODEL, ...conversationOnlyModels],
[SMART_AUTO_MODEL, conversationOnlyModels]
);
const selectableModels = useMemo(() => {
if (isDetailGenerationMode) {
return [SMART_AUTO_MODEL];
}
if (isVideoModel) {
return videoOnlyModels;
}
if (isAgentModel) {
return agentSelectableModels;
}
return imageOnlyModels;
}, [
SMART_AUTO_MODEL,
agentSelectableModels,
imageOnlyModels,
isAgentModel,
isDetailGenerationMode,
isVideoModel,
videoOnlyModels,
]);
const modelSelectValue = selectableModels.some((model) => model.id === selectedModelId)
? selectedModelId
: selectableModels[0]?.id || selectedModelId;
const selectDisplayModel =
selectableModels.find((model) => model.id === modelSelectValue) ||
currentModel ||
SMART_AUTO_MODEL;
const agentDisplayName =
currentModel?.displayName || selectDisplayModel?.displayName || "Agent模式";
const videoFrameSlots = useMemo(() => {
if (!isVideoModel || !videoParams) return [] as string[];
if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[];
if (videoParams.mode === "first-frame") return ["首帧"] as string[];
return [] as string[];
}, [isVideoModel, videoParams]);
const activeComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
if (isDetailGenerationMode) return "detail";
if (isVideoModel) return "video";
if (isAgentModel) return "agent";
return "image";
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
const agentUploadSlots = useMemo(() => {
if (
activeComposerMode !== "agent" ||
!selectedAgentPreset ||
selectedAgentPreset.type === "text"
) {
return [] as Array<{
slotIndex: number;
label: string;
}>;
}
return [
{ slotIndex: 0, label: "产品图1" },
{ slotIndex: 1, label: "产品图2" },
{ slotIndex: 2, label: "模特图" },
];
}, [activeComposerMode, selectedAgentPreset]);
const hasAgentUploadSlots =
activeComposerMode === "agent" && agentUploadSlots.length > 0;
const agentRequiredUploadCount = hasAgentUploadSlots ? 3 : 0;
// 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签
useEffect(() => {
if (pendingChatAttachments.length === 0) return;
if (isDetailGenerationMode) {
const normalized = pendingChatAttachments
.filter((item) => item?.url)
.map((item) => ({
url: item.url,
name: item.name || "素材图",
contentType: item.contentType || "image/jpeg",
})) as PickedObjectAttachment[];
if (normalized.length > 0) {
let inserted = 0;
const targetSlotIndex = detailSlotPickerIndex;
setDetailSlots((current) => {
const next = [...current];
const allIndexes = DETAIL_SLOT_CONFIGS.map((_, index) => index);
let priorityIndexes = allIndexes;
if (targetSlotIndex !== null && targetSlotIndex >= 0 && targetSlotIndex < DETAIL_SLOT_CONFIGS.length) {
const role = DETAIL_SLOT_CONFIGS[targetSlotIndex]?.role;
const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === role ? index : -1
).filter((index) => index >= 0);
const sameRoleStart = sameRoleIndexes.indexOf(targetSlotIndex);
const orderedSameRole =
sameRoleStart >= 0 ? sameRoleIndexes.slice(sameRoleStart) : sameRoleIndexes;
priorityIndexes = [targetSlotIndex, ...orderedSameRole.filter((index) => index !== targetSlotIndex)];
}
let cursor = 0;
for (let i = 0; i < priorityIndexes.length && cursor < normalized.length; i += 1) {
const slotIdx = priorityIndexes[i]!;
const canReplaceActive = targetSlotIndex !== null && slotIdx === targetSlotIndex && cursor === 0;
if (!canReplaceActive && next[slotIdx]) continue;
next[slotIdx] = normalized[cursor]!;
cursor += 1;
inserted += 1;
}
return next;
});
if (inserted > 0) {
toast.success(
inserted > 1 ? `已添加 ${inserted} 张到 AI详情素材槽位` : "已添加到 AI详情素材槽位"
);
if (targetSlotIndex !== null) {
setDetailSlotPickerIndex(null);
}
} else {
toast.info("详情素材槽位已满,请先替换或清空后再添加");
}
}
clearPendingChatAttachments();
return;
}
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => attachments[index] ?? null
);
const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url));
const toAdd = pendingChatAttachments
.filter((item) => item?.url && !existingUrls.has(item.url))
.map((item) => ({
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/jpeg",
})) as PickedObjectAttachment[];
let cursor = 0;
let inserted = 0;
for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) {
if (currentSlots[index]) continue;
currentSlots[index] = toAdd[cursor]!;
cursor += 1;
inserted += 1;
}
if (inserted > 0) {
const next = currentSlots
.slice(0, slotCount)
.filter((item): item is PickedObjectAttachment => Boolean(item));
setAttachments(next);
toast.success(
slotCount === 1
? "已添加到智能体图片槽位"
: `已添加到智能体槽位(${inserted}/${slotCount})`
);
} else if (toAdd.length > 0) {
toast.info("智能体图片槽位已满,请先替换或移除");
}
if (toAdd.length - inserted > 0) {
toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换智能体图片槽位`);
}
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
clearPendingChatAttachments();
return;
}
if (isVideoModel && videoFrameSlots.length > 0) {
const slotCount = videoFrameSlots.length;
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => attachments[index] ?? null
);
const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url));
const toAdd = pendingChatAttachments
.filter((item) => item?.url && !existingUrls.has(item.url))
.map((item) => ({
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/jpeg",
})) as PickedObjectAttachment[];
let cursor = 0;
let inserted = 0;
for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) {
if (currentSlots[index]) continue;
currentSlots[index] = toAdd[cursor]!;
cursor += 1;
inserted += 1;
}
if (inserted > 0) {
const next = currentSlots
.slice(0, slotCount)
.filter((item): item is PickedObjectAttachment => Boolean(item));
setAttachments(next);
toast.success(slotCount === 1 ? "已添加到视频首帧" : "已添加到首尾帧槽位");
} else if (toAdd.length > 0) {
toast.info("首尾帧槽位已满,请先替换或移除");
}
if (toAdd.length - inserted > 0) {
toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换首尾帧`);
}
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
clearPendingChatAttachments();
return;
}
const currentAttachments = [...attachments];
const existingUrls = new Set(currentAttachments.map((item) => item.url));
const toAdd = pendingChatAttachments.filter((item) => !existingUrls.has(item.url));
if (toAdd.length > 0) {
toAdd.forEach((item, index) => {
insertImageTagInEditor(item.url, item.name, currentAttachments.length + index);
});
setAttachments([...currentAttachments, ...toAdd]);
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
}
clearPendingChatAttachments();
}, [
pendingChatAttachments,
isDetailGenerationMode,
detailSlotPickerIndex,
hasAgentUploadSlots,
agentUploadSlots.length,
isVideoModel,
videoFrameSlots.length,
attachments,
insertImageTagInEditor,
clearPendingChatAttachments,
]);
const firstImageModelId = useMemo(
() => imageOnlyModels[0]?.id || null,
[imageOnlyModels]
);
const firstVideoModelId = useMemo(
() => videoOnlyModels[0]?.id || null,
[videoOnlyModels]
);
const activeModeLabel =
activeComposerMode === "video"
? "视频"
: activeComposerMode === "detail"
? "AI详情"
: activeComposerMode === "image"
? "生图"
: "Agent";
const shouldMaskAgentRuntimeModelName =
activeComposerMode === "agent" || selectedModelId === "auto";
const getAgentSafeModelName = useCallback(
(modelName?: string | null) => {
const normalized = String(modelName || "").trim();
if (!shouldMaskAgentRuntimeModelName) {
return normalized || undefined;
}
return normalized ? "agent model" : "agent model";
},
[shouldMaskAgentRuntimeModelName]
);
// 默认选择模型(根据系统设置)
useEffect(() => {
if (!selectedModelId && siteSettings) {
currentModelIdRef.current = "auto";
setSelectedModelId("auto");
}
}, [selectedModelId, siteSettings]);
useEffect(() => {
if (!aiDetailGenerationEnabled && isDetailGenerationMode) {
setIsDetailGenerationMode(false);
}
}, [aiDetailGenerationEnabled, isDetailGenerationMode]);
useEffect(() => {
if (!modelSelectValue || modelSelectValue === selectedModelId) return;
currentModelIdRef.current = modelSelectValue;
setSelectedModelId(modelSelectValue);
}, [modelSelectValue, selectedModelId]);
useEffect(() => {
if (!selectedAgentPresetId) return;
if (!selectedAgentPreset?.defaultModelId) return;
if (currentModelIdRef.current === selectedAgentPreset.defaultModelId) return;
currentModelIdRef.current = selectedAgentPreset.defaultModelId;
setSelectedModelId(selectedAgentPreset.defaultModelId);
if (isDetailGenerationMode) {
setIsDetailGenerationMode(false);
}
}, [
isDetailGenerationMode,
selectedAgentPreset,
selectedAgentPresetId,
]);
useEffect(() => {
if (isDetailGenerationMode) {
setAttachments([]);
setDetailSections({ required: true, strategy: false, assets: true, archives: true });
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove());
}
} else {
setDetailProgress({ status: "idle", stage: "", current: 0, total: 0 });
}
}, [isDetailGenerationMode]);
const currentModelIdRef = useRef(selectedModelId);
const isVideoModelRef = useRef(isVideoModel);
useEffect(() => {
currentModelIdRef.current = selectedModelId;
}, [selectedModelId]);
useEffect(() => {
imageParamsRef.current = imageParams;
}, [imageParams]);
useEffect(() => {
videoParamsRef.current = videoParams;
}, [videoParams]);
useEffect(() => {
deepThinkingEnabledRef.current = deepThinkingEnabled;
}, [deepThinkingEnabled]);
useEffect(() => {
isVideoModelRef.current = isVideoModel;
}, [isVideoModel]);
useEffect(() => {
if (!isVideoModel) return;
const normalized = normalizeVideoParamsForProvider(
videoParams,
currentModel?.providerType,
currentModel?.videoToolCapability
);
if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) {
setVideoParams(normalized);
}
}, [isVideoModel, currentModel?.providerType, currentModel?.videoToolCapability, videoParams]);
useEffect(() => {
if (!isVideoModel || videoFrameSlots.length === 0) return;
const maxAttachments = videoFrameSlots.length;
setAttachments((current) => {
if (current.length <= maxAttachments) return current;
return current.slice(0, maxAttachments);
});
}, [isVideoModel, videoFrameSlots.length]);
useEffect(() => {
if (!hasAgentUploadSlots) return;
const maxAttachments = agentUploadSlots.length;
setAttachments((current) => {
if (current.length <= maxAttachments) return current;
return current.slice(0, maxAttachments);
});
}, [agentUploadSlots.length, hasAgentUploadSlots]);
useEffect(() => {
if (!currentModel) return;
if (currentModel.id !== "auto" && isDetailGenerationMode) {
setIsDetailGenerationMode(false);
}
if (isVideoModel) {
setLastVideoModelId(currentModel.id);
return;
}
if (isConversationModel) {
setLastConversationModelId(currentModel.id);
return;
}
if (currentModel.id !== "auto") {
setLastImageModelId(currentModel.id);
}
}, [currentModel, isConversationModel, isDetailGenerationMode, isVideoModel]);
const handleSelectGenerationMode = useCallback(
(mode: "agent" | "image" | "video" | "detail") => {
if (mode === "agent") {
const preferredAgentModelId =
allModels.find(
(model) =>
model.id === lastConversationModelId &&
model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || "auto";
currentModelIdRef.current = preferredAgentModelId;
setSelectedModelId(preferredAgentModelId);
setIsDetailGenerationMode(false);
return;
}
if (mode === "detail") {
if (!aiDetailGenerationEnabled) {
toast.error("AI详情生成功能未开启");
return;
}
currentModelIdRef.current = "auto";
setSelectedModelId("auto");
setIsDetailGenerationMode(true);
setIsDetailWorkbenchCollapsed(true);
return;
}
if (mode === "image") {
const preferredImageModelId =
allModels.find(
(model) =>
model.id === lastImageModelId &&
model.id !== "auto" &&
!model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || firstImageModelId;
if (!preferredImageModelId) {
toast.error("当前没有可用的图像模型");
return;
}
currentModelIdRef.current = preferredImageModelId;
setSelectedModelId(preferredImageModelId);
setIsDetailGenerationMode(false);
return;
}
const preferredVideoModelId =
allModels.find(
(model) => model.id === lastVideoModelId && VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || firstVideoModelId;
if (!preferredVideoModelId) {
toast.error("当前没有可用的视频模型");
return;
}
currentModelIdRef.current = preferredVideoModelId;
setSelectedModelId(preferredVideoModelId);
setIsDetailGenerationMode(false);
},
[
aiDetailGenerationEnabled,
allModels,
firstImageModelId,
firstVideoModelId,
lastConversationModelId,
lastImageModelId,
lastVideoModelId,
]
);
const updateDetailBriefField = useCallback(
(key: keyof DetailBriefForm, value: string) => {
setDetailBrief((prev) => ({ ...prev, [key]: value }));
},
[]
);
const normalizedDetailBrief = useMemo(() => {
const normalizeValue = (value: string) => value.replace(/\s+/g, " ").trim();
return {
productSpu: normalizeValue(detailBrief.productSpu),
demandType: normalizeValue(detailBrief.demandType),
productName: normalizeValue(detailBrief.productName),
targetAudience: normalizeValue(detailBrief.targetAudience),
mainUser: normalizeValue(detailBrief.mainUser),
usageScenario: normalizeValue(detailBrief.usageScenario),
triggerMoment: normalizeValue(detailBrief.triggerMoment),
coreNeed: normalizeValue(detailBrief.coreNeed),
riskConcern: normalizeValue(detailBrief.riskConcern),
expectedResult: normalizeValue(detailBrief.expectedResult),
coreSellingPoint: normalizeValue(detailBrief.coreSellingPoint),
positioningStatement: normalizeValue(detailBrief.positioningStatement),
userVoice: normalizeValue(detailBrief.userVoice),
competitorReference: normalizeValue(detailBrief.competitorReference),
priceBand: normalizeValue(detailBrief.priceBand),
brandTone: normalizeValue(detailBrief.brandTone),
bannedWords: normalizeValue(detailBrief.bannedWords),
};
}, [detailBrief]);
const detailValidationIssues = useMemo(() => {
const issues: string[] = [];
if (!normalizedDetailBrief.targetAudience) issues.push("缺少主购买人群");
if (!normalizedDetailBrief.usageScenario) issues.push("缺少需求场景");
if (!normalizedDetailBrief.coreNeed) issues.push("缺少核心需求");
const productCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
if (productCount < 5) issues.push(`产品图不足(${productCount}/5)`);
if (DETAIL_MODEL_SLOT_INDEX < 0 || !detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) {
issues.push("缺少模特图(1/1)");
}
return issues;
}, [detailSlots, normalizedDetailBrief]);
const hasGeneratedDetailImages = useMemo(
() =>
nodes.some(
(node) =>
node.type === "image" &&
Boolean((node.data as any)?.metadata?.detailSectionIndex)
),
[nodes]
);
const toggleDetailSection = useCallback((key: DetailSectionKey) => {
setDetailSections((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
useEffect(() => {
let isCancelled = false;
const loadDetailArchives = async () => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "GET",
cache: "no-store",
});
const result = await response.json().catch(() => ({}));
if (isCancelled) return;
if (!response.ok || !result?.success) {
setDetailArchives([]);
return;
}
const parsed = Array.isArray(result.data) ? (result.data as DetailArchive[]) : [];
const sanitized = parsed
.filter((item) => item?.id && item?.createdAt)
.map((item) => ({
id: String(item.id),
title: String(item.title || "未命名详情项目"),
prompt: String(item.prompt || ""),
generationMode: normalizeDetailGenerationMode((item as any).generationMode),
brief: {
productSpu: String(item.brief?.productSpu || ""),
demandType: String(item.brief?.demandType || ""),
productName: String(item.brief?.productName || ""),
targetAudience: String(item.brief?.targetAudience || ""),
mainUser: String(item.brief?.mainUser || ""),
usageScenario: String(item.brief?.usageScenario || ""),
triggerMoment: String(item.brief?.triggerMoment || ""),
coreNeed: String(item.brief?.coreNeed || ""),
riskConcern: String(item.brief?.riskConcern || ""),
expectedResult: String(item.brief?.expectedResult || ""),
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
positioningStatement: String(item.brief?.positioningStatement || ""),
userVoice: String(item.brief?.userVoice || ""),
competitorReference: String(item.brief?.competitorReference || ""),
priceBand: String(item.brief?.priceBand || ""),
brandTone: String(item.brief?.brandTone || ""),
bannedWords: String(item.brief?.bannedWords || ""),
},
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = item.slots?.[index];
if (!slot?.url) return null;
return {
url: String(slot.url),
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
contentType: String(slot.contentType || "image/jpeg"),
} as PickedObjectAttachment;
}),
createdAt:
typeof item.createdAt === "number"
? item.createdAt
: (() => {
const timestamp = Date.parse(String(item.createdAt || ""));
return Number.isFinite(timestamp) ? timestamp : Date.now();
})(),
updatedAt:
typeof (item as any).updatedAt === "number"
? Number((item as any).updatedAt)
: (() => {
const timestamp = Date.parse(String((item as any).updatedAt || ""));
return Number.isFinite(timestamp) ? timestamp : undefined;
})(),
summary: item.summary ? String(item.summary) : undefined,
imageCount: Number(item.imageCount || 0) || undefined,
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
}))
.sort(
(a, b) =>
(b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt)
);
setDetailArchives(sanitized.slice(0, 30));
} catch (error) {
if (isCancelled) return;
console.error("[CanvasChat] Failed to load detail archives:", error);
setDetailArchives([]);
}
};
void loadDetailArchives();
return () => {
isCancelled = true;
};
}, [canvasId]);
useEffect(() => {
if (videoFrameSlots.length === 0) return;
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv) return;
editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove());
}, [videoFrameSlots.length]);
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
const appendStreamMediaToCanvas = useCallback(
async (payload: CanvasMediaStreamPayload) => {
const imageUrls = Array.isArray(payload.imageUrls)
? payload.imageUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
: [];
const videoUrls = Array.isArray(payload.videoUrls)
? payload.videoUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
: [];
if (imageUrls.length === 0 && videoUrls.length === 0) {
return;
}
const latestNodes = useCanvasStore.getState().nodes;
const existingImageUrlKeys = new Set(
latestNodes
.filter((node) => node.type === "image" && (node.data as any)?.src)
.map((node) => normalizeImageIdentityUrl(String((node.data as any)?.src || "")))
.filter(Boolean)
);
const existingMessageImageKeys = new Set(
latestNodes
.filter((node) => node.type === "image")
.map((node) => {
const data = ((node.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceMessageId || !sourceImageKey) return "";
return `${sourceMessageId}::${sourceImageKey}`;
})
.filter(Boolean)
);
const existingVideoUrlKeys = new Set(
latestNodes
.filter((node) => node.type === "video" && (node.data as any)?.src)
.map((node) => normalizeMediaUrl(String((node.data as any)?.src || "")))
.filter(Boolean)
);
const sourceImageUrls = Array.isArray(payload.sourceImageUrls)
? payload.sourceImageUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
: [];
const messageId = String(payload.messageId || "").trim();
const generationPrompt = String(payload.prompt || "").trim() || "对话生成";
const agentPresetId = String(payload.agentPresetId || "").trim() || undefined;
const agentPresetName = String(payload.agentPresetName || "").trim() || undefined;
const agentCategoryId = String(payload.agentCategoryId || "").trim() || undefined;
const agentCategoryName = String(payload.agentCategoryName || "").trim() || undefined;
const agentExecutionSnapshotId =
String(payload.agentExecutionSnapshotId || "").trim() || undefined;
let addedImageCount = 0;
let addedVideoCount = 0;
for (let index = 0; index < imageUrls.length; index += 1) {
const url = imageUrls[index]!;
const sourceImageKey = normalizeImageIdentityUrl(url);
if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) {
continue;
}
const messageImageKey = messageId ? `${messageId}::${sourceImageKey}` : "";
if (messageImageKey && existingMessageImageKeys.has(messageImageKey)) {
continue;
}
existingImageUrlKeys.add(sourceImageKey);
if (messageImageKey) {
existingMessageImageKeys.add(messageImageKey);
}
const dimensions = await getImageDimensions(url);
const currentNodes = useCanvasStore.getState().nodes;
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
const spacing = 20;
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
sourceImageUrls[0] ||
"";
const compareSourceUrl = String(compareSourceUrlRaw || "").trim();
const normalizedCompareSource = normalizeImageIdentityUrl(compareSourceUrl);
const canUseCompareSource =
Boolean(normalizedCompareSource) &&
normalizedCompareSource !== sourceImageKey;
const sourceNodeId = canUseCompareSource
? useCanvasStore
.getState()
.nodes.find((node) => {
if (node.type !== "image") return false;
const src = String(((node.data as any) || {}).src || "").trim();
return normalizeImageIdentityUrl(src) === normalizedCompareSource;
})?.id || ""
: "";
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "图片",
prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
sourceMessageId: messageId || undefined,
sourceImageKey,
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
agentPresetId,
agentPresetName,
agentCategoryId,
agentCategoryName,
agentExecutionSnapshotId,
},
},
connections: sourceNodeId ? [sourceNodeId] : [],
});
void autoNameGeneratedImageNode(nodeId, url);
addedImageCount += 1;
}
for (let index = 0; index < videoUrls.length; index += 1) {
const url = videoUrls[index]!;
const key = normalizeMediaUrl(url);
if (!key || existingVideoUrlKeys.has(key)) {
continue;
}
existingVideoUrlKeys.add(key);
const currentNodes = useCanvasStore.getState().nodes;
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
const x = 120 + (existingNodes % 2) * 420;
const y = 120 + Math.floor(existingNodes / 2) * 260;
addNode({
type: "video",
position: { x, y },
size: { width: 380, height: 220 },
data: {
src: url,
status: "completed",
prompt: generationPrompt,
metadata: {
agentPresetId,
agentPresetName,
agentCategoryId,
agentCategoryName,
agentExecutionSnapshotId,
},
},
connections: [],
});
addedVideoCount += 1;
}
if (addedImageCount > 0 || addedVideoCount > 0) {
const notice = [
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
]
.filter(Boolean)
.join("、");
toast.success(`已将 ${notice} 添加到画布`);
}
},
[addNode, autoNameGeneratedImageNode]
);
const {
messages,
setMessages,
sendMessage,
status,
} = useChat<ChatMessage>({
id: activeChatId,
messages: mergedInitialMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '')
: request.id;
console.log("[CanvasChat] Preparing request:", {
id: rawId,
modelId: currentModelIdRef.current,
imageParams: imageParamsRef.current,
contextPayload: buildContextPayload(),
lastMessage: request.messages.at(-1),
});
const hiddenPrompt = hiddenPromptRef.current || undefined;
const autoContext = autoContextRef.current || undefined;
const contextPayload = buildContextPayload();
const requestModel = allModels.find((model) => model.id === currentModelIdRef.current);
const requestIsVideoModel = Boolean(
requestModel && VIDEO_PROVIDER_TYPES.has(requestModel.providerType)
);
const requestIsConversationModel = Boolean(
requestModel &&
requestModel.id !== "auto" &&
requestModel.isMultimodal &&
!requestIsVideoModel
);
hiddenPromptRef.current = null;
autoContextRef.current = null;
return {
body: {
id: rawId,
message: request.messages.at(-1),
agentPresetId:
activeComposerMode === "agent" && selectedAgentPreset?.id
? selectedAgentPreset.id
: undefined,
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: "private",
deepThinkingEnabled: requestIsConversationModel
? false
: requestIsVideoModel || currentModelIdRef.current === "auto"
? undefined
: deepThinkingEnabledRef.current,
videoParams: requestIsVideoModel ? videoParamsRef.current || undefined : undefined,
imageParams:
requestIsVideoModel || requestIsConversationModel
? undefined
: imageParamsRef.current || undefined,
hiddenPrompt,
autoContext,
contextPayload,
...request.body,
},
};
},
}),
onData: (dataPart: any) => {
if (dataPart.type === "data-imageGenerationStatus") {
try {
const payload: ImageGenerationStatusStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
if (payload.phase === "completed") {
clearStreamImageGenerationStateForChat(activeChatId);
return;
}
setStreamImageGenerationStateForChat(activeChatId, {
phase: payload.phase || "processing",
providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined,
});
} catch (e) {
console.error("[CanvasChat] Failed to parse data-imageGenerationStatus:", e);
}
return;
}
if (dataPart.type === "data-canvasMedia") {
try {
const payload: CanvasMediaStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
void appendStreamMediaToCanvas(payload);
} catch (e) {
console.error("[CanvasChat] Failed to parse data-canvasMedia:", e);
}
return;
}
// 处理后端返回的自定义消息
if (dataPart.type === "data-appendMessage") {
try {
const messageData = typeof dataPart.data === 'string'
? JSON.parse(dataPart.data)
: dataPart.data;
const hasImageGenStartMarker = Array.isArray(messageData.parts)
? messageData.parts.some(
(part: any) =>
part?.type === "text" &&
typeof part?.text === "string" &&
part.text.includes("<!-- IMAGE_GENERATION_START -->")
)
: false;
const hasImageGenEndMarker = Array.isArray(messageData.parts)
? messageData.parts.some(
(part: any) =>
part?.type === "text" &&
typeof part?.text === "string" &&
part.text.includes("<!-- IMAGE_GENERATION_END -->")
)
: false;
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments
: [];
const emptyNearestUserImageKeys = new Set<string>();
const incomingImageUrls = normalizedParts.flatMap((part: any) =>
part?.type === "text" && typeof part?.text === "string"
? extractAssistantImageUrls(part.text, emptyNearestUserImageKeys)
: typeof part?.url === "string" && /^https?:\/\//i.test(part.url)
? [part.url]
: []
);
const incomingVideoUrls = normalizedParts.flatMap((part: any) =>
part?.type === "text" && typeof part?.text === "string"
? extractVideoUrls(part.text)
: typeof part?.url === "string" &&
/^https?:\/\//i.test(part.url) &&
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(part.url)
? [part.url]
: []
);
normalizedAttachments.forEach((attachment: any) => {
const rawUrl = String(attachment?.url || "").trim();
if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) return;
const contentType = String(attachment?.contentType || "").toLowerCase();
if (
contentType.startsWith("video/") ||
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(rawUrl)
) {
incomingVideoUrls.push(rawUrl);
return;
}
incomingImageUrls.push(rawUrl);
});
if (hasImageGenStartMarker) {
const previousState =
streamImageGenerationStateByChatId[activeChatId] || null;
setStreamImageGenerationStateForChat(activeChatId, {
phase: "processing",
providerType:
previousState?.providerType ||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType),
modelName:
previousState?.modelName ||
getAgentSafeModelName(currentModel?.displayName),
imageCount:
previousState?.imageCount ||
Math.max(1, Number(imageParamsRef.current?.imageCount || 1)),
});
}
if (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) {
clearStreamImageGenerationStateForChat(activeChatId);
}
setMessages((prevMessages) => {
const existingIndex = prevMessages.findIndex(
(message) => message.id === messageData.id
);
if (existingIndex >= 0) {
const existingMessage = prevMessages[existingIndex];
const updatedMessage: any = {
...existingMessage,
role: messageData.role || existingMessage.role,
content:
normalizedParts.find((part: any) => part?.type === "text")?.text ||
(existingMessage as any).content ||
"",
parts: normalizedParts,
attachments: normalizedAttachments,
};
const nextMessages = [...prevMessages];
nextMessages[existingIndex] = updatedMessage;
return nextMessages;
}
const newMessage: any = {
id: messageData.id,
role: messageData.role,
content: normalizedParts.find((part: any) => part?.type === "text")?.text || "",
createdAt: new Date(),
parts: normalizedParts,
attachments: normalizedAttachments,
};
return [...prevMessages, newMessage];
});
} catch (e) {
console.error("[CanvasChat] Failed to parse data-appendMessage:", e);
}
return;
}
if (dataPart.type === "data-videoStoryboard") {
try {
const payload: VideoStoryboardStreamData =
typeof dataPart.data === "string"
? JSON.parse(dataPart.data)
: dataPart.data;
if (!payload || typeof payload !== "object") return;
if (payload.stage === "start") {
const taskId = String(payload.taskId || "").trim();
if (!taskId) return;
if (storyboardNodeMapRef.current[taskId]) return;
const totalShots = Math.max(1, Math.min(9, Number(payload.totalShots || 9)));
const layoutCols =
Math.max(1, Math.min(3, Number(payload.cols || (totalShots === 4 ? 2 : 3)))) || 3;
const shotNodeMap: Record<number, string> = {};
const currentNodes = useCanvasStore.getState().nodes;
const baseOffset = currentNodes.length;
const cardWidth = 320;
const cardHeight = 196;
const gapX = 30;
const gapY = 24;
const startX = 120 + (baseOffset % 2) * 420;
const startY = 120 + Math.floor(baseOffset / 2) * 260;
for (let shotIndex = 1; shotIndex <= totalShots; shotIndex += 1) {
const shotData = payload.shots?.find((item) => Number(item.shotIndex) === shotIndex);
const col = (shotIndex - 1) % layoutCols;
const row = Math.floor((shotIndex - 1) / layoutCols);
const nodeId = addNode({
type: "image",
position: {
x: startX + col * (cardWidth + gapX),
y: startY + row * (cardHeight + gapY),
},
size: { width: cardWidth, height: cardHeight },
data: {
src: shotData?.tileImageUrl || "",
status: "generating",
title: `镜头 ${shotIndex}`,
prompt: shotData?.prompt || `分镜 ${shotIndex} 生成中`,
originalSrc: shotData?.tileImageUrl || "",
metadata: {
storyboardTaskId: taskId,
shotIndex,
stage: "preview",
},
},
connections: [],
});
shotNodeMap[shotIndex] = nodeId;
}
storyboardNodeMapRef.current[taskId] = shotNodeMap;
toast.message(`分镜预览任务已开始(${totalShots} 段)`);
return;
}
if (payload.stage === "hd_shot_completed") {
const taskId = String(payload.taskId || "").trim();
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
const imageUrl = String(payload.imageUrl || "").trim();
if (!taskId || !shotIndex || !imageUrl) return;
let nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
if (!nodeId) {
nodeId = addNode({
type: "image",
position: { x: 140 + ((shotIndex - 1) % 3) * 350, y: 150 + Math.floor((shotIndex - 1) / 3) * 230 },
size: { width: 320, height: 196 },
data: {
src: imageUrl,
status: "completed",
title: `镜头 ${shotIndex}`,
prompt: payload.prompt || `镜头 ${shotIndex}`,
metadata: {
storyboardTaskId: taskId,
shotIndex,
stage: "preview",
},
},
connections: [],
});
if (!storyboardNodeMapRef.current[taskId]) {
storyboardNodeMapRef.current[taskId] = {};
}
storyboardNodeMapRef.current[taskId]![shotIndex] = nodeId;
return;
}
const currentNode = useCanvasStore.getState().getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(nodeId, {
data: {
...currentData,
src: imageUrl,
status: "completed",
title: currentData.title || `镜头 ${shotIndex}`,
prompt: payload.prompt || currentData.prompt || `镜头 ${shotIndex}`,
metadata: {
...(currentData.metadata || {}),
storyboardTaskId: taskId,
shotIndex,
stage: "preview",
},
},
} as any);
return;
}
if (payload.stage === "hd_shot_failed") {
const taskId = String(payload.taskId || "").trim();
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
if (!taskId || !shotIndex) return;
const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
if (!nodeId) return;
const currentNode = useCanvasStore.getState().getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(nodeId, {
data: {
...currentData,
status: "failed",
metadata: {
...(currentData.metadata || {}),
previewError: payload.error || "预览图生成失败",
},
},
} as any);
return;
}
if (payload.stage === "awaiting_confirmation") {
const taskId = String(payload.taskId || "").trim();
if (!taskId) return;
const previewShots = Array.isArray(payload.shots) ? payload.shots : [];
for (const shot of previewShots) {
const shotIndex = Math.max(1, Number(shot.shotIndex || 0));
const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
if (!nodeId) continue;
const currentNode = useCanvasStore.getState().getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(nodeId, {
data: {
...currentData,
src: String(shot.previewImageUrl || currentData.src || shot.tileImageUrl || ""),
originalSrc: String(shot.tileImageUrl || currentData.originalSrc || ""),
status: "completed",
title: currentData.title || `镜头 ${shotIndex}`,
prompt: shot.prompt || currentData.prompt || `镜头 ${shotIndex}`,
metadata: {
...(currentData.metadata || {}),
storyboardTaskId: taskId,
shotIndex,
stage: "awaiting_confirmation",
},
},
} as any);
}
toast.message(payload.message || "分镜预览已完成,请先确认镜头内容");
return;
}
if (payload.stage === "shot_completed") {
const taskId = String(payload.taskId || "").trim();
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
const videoUrl = String(payload.videoUrl || "").trim();
if (!taskId || !shotIndex || !videoUrl) return;
let nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
if (!nodeId) {
nodeId = addNode({
type: "video",
position: { x: 140 + ((shotIndex - 1) % 3) * 350, y: 150 + Math.floor((shotIndex - 1) / 3) * 230 },
size: { width: 320, height: 196 },
data: {
src: videoUrl,
status: "completed",
prompt: `分镜 ${shotIndex}`,
},
connections: [],
});
if (!storyboardNodeMapRef.current[taskId]) {
storyboardNodeMapRef.current[taskId] = {};
}
storyboardNodeMapRef.current[taskId]![shotIndex] = nodeId;
return;
}
const currentNode = useCanvasStore.getState().getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(nodeId, {
type: "video",
data: {
src: videoUrl,
status: "completed",
prompt: currentData.prompt || `分镜 ${shotIndex}`,
coverImage: currentData.src || currentData.originalSrc || "",
metadata: {
...(currentData.metadata || {}),
storyboardTaskId: taskId,
shotIndex,
stage: "video",
},
},
} as any);
return;
}
if (payload.stage === "shot_failed") {
const taskId = String(payload.taskId || "").trim();
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
if (!taskId || !shotIndex) return;
const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
if (!nodeId) return;
const currentNode = useCanvasStore.getState().getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(nodeId, {
data: {
...currentData,
status: "failed",
},
} as any);
return;
}
if (payload.stage === "completed") {
const successCount = Math.max(0, Number(payload.successCount || 0));
const failedCount = Math.max(0, Number(payload.failedCount || 0));
toast.success(`分镜视频完成:成功 ${successCount},失败 ${failedCount}`);
return;
}
if (payload.stage === "failed") {
toast.error(payload.error || "分镜视频任务失败");
}
} catch (e) {
console.error("[CanvasChat] Failed to parse data-videoStoryboard:", e);
}
}
},
onError: (error) => {
clearStreamImageGenerationStateForChat(activeChatId);
releaseSendGuard(activeChatId);
const fallbackMessage =
error instanceof Error ? error.message : "生成失败";
const responseData = (error as any)?.data;
const isInsufficientPoints =
responseData?.error === "insufficient_points" ||
/积分不足/.test(fallbackMessage);
if (isInsufficientPoints) {
const balance = Math.max(0, Number(responseData?.balance ?? 0));
const expectedCost = Math.max(0, Number(responseData?.expectedCost ?? 0));
const unitCost = Math.max(0, Number(responseData?.unitCost ?? 0));
const imageCount =
unitCost > 0 && expectedCost > 0
? Math.max(1, Math.round(expectedCost / unitCost))
: Math.max(1, Math.round(Number(imageParamsRef.current?.imageCount || 1)));
setInsufficientPointsState({
balance,
expectedCost,
unitCost,
imageCount,
});
}
toast.error(fallbackMessage || "生成失败");
},
});
useEffect(() => {
if (status === "ready") {
releaseSendGuard(activeChatId);
}
}, [activeChatId, releaseSendGuard, status]);
useEffect(() => {
const launchPayload = pendingLaunchRef.current;
if (!launchPayload?.autoSend) return;
if (!selectedModelId || status !== "ready") return;
const launchAttachments = launchPayload.attachments || [];
const textContent =
launchPayload.prompt?.trim() ||
(launchAttachments.length > 0 ? "请基于这些图片继续生成设计方案" : "");
if (!textContent && launchAttachments.length === 0) {
pendingLaunchRef.current = null;
return;
}
const parts: any[] = launchAttachments.map((attachment) => ({
type: "file" as const,
mediaType: attachment.contentType || "image/jpeg",
name: attachment.name || "参考图",
url: attachment.url,
assetId: attachment.assetId,
}));
if (textContent) {
parts.push({ type: "text" as const, text: textContent });
}
// 强制同步当前模型,避免模型切换后瞬时发送命中旧模型
currentModelIdRef.current = selectedModelId;
sendMessage({
role: "user",
parts,
});
hasHydratedHistoryRef.current = true;
pendingLaunchRef.current = null;
setInput("");
setAttachments([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [selectedModelId, status, sendMessage, clearSelection, launchAutoSendTick]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>("");
useEffect(() => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
if (!historyData || !rawChatId) return;
const shouldForceHydrate = forceHistoryHydrationRef.current === rawChatId;
if ((status === "streaming" || status === "submitted") && !shouldForceHydrate) return;
const historyMessages = historyData.messages || [];
const lastMessageId = historyMessages.at(-1)?.id || "none";
const signature = `${rawChatId}:${historyMessages.length}:${lastMessageId}`;
if (loadedHistorySignatureRef.current === signature) return;
loadedHistorySignatureRef.current = signature;
processedMessageIds.current = new Set();
hasHydratedHistoryRef.current = true;
forceHistoryHydrationRef.current = null;
setMessages(historyMessages);
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
historyScrollTimerRef.current = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
historyScrollTimerRef.current = null;
}, 160);
return () => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
};
}, [historyData, rawChatId, setMessages, status]);
// 监听消息变化,提取图片并添加到画布
// 仅处理历史同步后的新消息,避免刷新后把历史图片重复回灌到画布
const processedMessageIds = useRef<Set<string>>(new Set());
const hasHydratedHistoryRef = useRef(false);
useEffect(() => {
if (!hasInitializedChatStateRef.current) {
hasInitializedChatStateRef.current = true;
return;
}
loadedHistorySignatureRef.current = "";
processedMessageIds.current.clear();
hasHydratedHistoryRef.current = false;
hiddenPromptRef.current = null;
autoContextRef.current = null;
pendingLaunchRef.current = null;
setMessageViewportKey((prev) => prev + 1);
setMessages([]);
setInput("");
setAttachments([]);
setIsSessionMenuOpen(false);
setDetailSlotPickerIndex(null);
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
}, [activeChatId, setMessages]);
useEffect(() => {
if (!activeChatId) return;
const derivedTitle = deriveSessionTitleFromMessages(messages);
const now = Date.now();
setChatSessions((prevSessions) => {
const currentIndex = prevSessions.findIndex(
(session) => session.id === activeChatId
);
const fallbackTitle = `对话 ${prevSessions.length + 1}`;
if (currentIndex < 0) {
const created: CanvasChatSession = {
id: activeChatId,
title: derivedTitle || fallbackTitle,
createdAt: now,
updatedAt: now,
};
return [created, ...prevSessions];
}
const current = prevSessions[currentIndex];
const nextTitle = derivedTitle || current.title || fallbackTitle;
const shouldUpdate =
current.title !== nextTitle || current.updatedAt !== now;
if (!shouldUpdate) return prevSessions;
const nextSessions = [...prevSessions];
nextSessions[currentIndex] = {
...current,
title: nextTitle,
updatedAt: now,
};
nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
return nextSessions;
});
}, [activeChatId, messages]);
useEffect(() => {
if (!hasHydratedHistoryRef.current) return;
let cancelled = false;
const processNewAssistantMessages = async () => {
const existingImageUrlKeys = new Set(
nodes
.filter((n) => n.type === "image" && (n.data as any)?.src)
.map((n) => normalizeImageIdentityUrl((n.data as any).src))
.filter(Boolean)
);
const existingMessageImageKeys = new Set(
nodes
.filter((n) => n.type === "image")
.map((n) => {
const data = ((n.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
if (!sourceMessageId) return "";
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceImageKey) return "";
return `${sourceMessageId}::${sourceImageKey}`;
})
.filter(Boolean)
);
const existingVideoUrlKeys = new Set(
nodes
.filter((n) => n.type === "video" && (n.data as any)?.src)
.map((n) => normalizeMediaUrl((n.data as any).src))
.filter(Boolean)
);
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
if (cancelled) return;
const message = messages[messageIndex];
if (message.role !== "assistant" || processedMessageIds.current.has(message.id)) {
continue;
}
const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex);
const nearestUserImageUrls = getNearestUserImageUrls(messages, messageIndex);
const imageUrlSet = new Set<string>();
const videoUrlSet = new Set<string>();
const parts = (message.parts || []) as any[];
parts.forEach((part) => {
if (!part) return;
if (part.type === "text" && typeof part.text === "string") {
extractAssistantImageUrls(part.text, nearestUserImageKeys).forEach((url) =>
imageUrlSet.add(url)
);
extractVideoUrls(part.text).forEach((url) => videoUrlSet.add(url));
return;
}
const rawUrl =
typeof part.url === "string" && part.url.trim()
? part.url.trim()
: typeof part.data === "string" && part.data.trim()
? part.data.trim()
: "";
if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) return;
const mediaType = String(
part.mediaType || part.mimeType || part.contentType || ""
).toLowerCase();
if (
mediaType.startsWith("video/") ||
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(rawUrl)
) {
videoUrlSet.add(rawUrl);
return;
}
imageUrlSet.add(rawUrl);
});
const messageAttachments = ((message as any).attachments || []) as Array<{
url?: string;
contentType?: string;
}>;
messageAttachments.forEach((attachment) => {
const rawUrl = String(attachment?.url || "").trim();
if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) return;
const contentType = String(attachment?.contentType || "").toLowerCase();
if (
contentType.startsWith("video/") ||
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(rawUrl)
) {
videoUrlSet.add(rawUrl);
return;
}
imageUrlSet.add(rawUrl);
});
const legacyContent =
typeof (message as any).content === "string"
? (message as any).content
: "";
if (legacyContent) {
extractAssistantImageUrls(legacyContent, nearestUserImageKeys).forEach((url) =>
imageUrlSet.add(url)
);
extractVideoUrls(legacyContent).forEach((url) => videoUrlSet.add(url));
}
const imageUrls = Array.from(imageUrlSet);
const videoUrls = Array.from(videoUrlSet);
if (imageUrls.length === 0 && videoUrls.length === 0) continue;
processedMessageIds.current.add(message.id);
const generationPrompt = findNearestUserPrompt(messages, messageIndex);
let addedImageCount = 0;
let addedVideoCount = 0;
const newImageEntries = imageUrls.reduce<Array<{ url: string; sourceImageKey: string }>>(
(acc, url) => {
const sourceImageKey = normalizeImageIdentityUrl(url);
if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) {
return acc;
}
const messageImageKey = `${message.id}::${sourceImageKey}`;
if (existingMessageImageKeys.has(messageImageKey)) {
return acc;
}
existingImageUrlKeys.add(sourceImageKey);
existingMessageImageKeys.add(messageImageKey);
acc.push({ url, sourceImageKey });
return acc;
},
[]
);
for (let index = 0; index < newImageEntries.length; index += 1) {
if (cancelled) return;
const imageEntry = newImageEntries[index];
const url = imageEntry.url;
const messageImageKey = `${message.id}::${imageEntry.sourceImageKey}`;
const hasDuplicateInStore = useCanvasStore
.getState()
.nodes.some((node) => {
if (node.type !== "image") return false;
const data = ((node.data as any) || {}) as Record<string, any>;
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
const sourceImageKey = normalizeImageIdentityUrl(
String(metadata.sourceImageKey || data.src || "").trim()
);
if (!sourceImageKey) return false;
const nodeMessageImageKey = sourceMessageId
? `${sourceMessageId}::${sourceImageKey}`
: "";
return (
nodeMessageImageKey === messageImageKey ||
sourceImageKey === imageEntry.sourceImageKey
);
});
if (hasDuplicateInStore) {
continue;
}
const dimensions = await getImageDimensions(url);
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
const spacing = 20;
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
nearestUserImageUrls[0] ||
"";
const compareSourceUrl = String(compareSourceUrlRaw || "").trim();
const normalizedCompareSource = normalizeImageIdentityUrl(compareSourceUrl);
const canUseCompareSource =
Boolean(normalizedCompareSource) &&
normalizedCompareSource !== imageEntry.sourceImageKey;
const sourceNodeId = canUseCompareSource
? useCanvasStore
.getState()
.nodes.find((node) => {
if (node.type !== "image") return false;
const src = String(((node.data as any) || {}).src || "").trim();
return normalizeImageIdentityUrl(src) === normalizedCompareSource;
})?.id || ""
: "";
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "图片",
prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey,
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
},
},
connections: sourceNodeId ? [sourceNodeId] : [],
});
void autoNameGeneratedImageNode(nodeId, url);
addedImageCount += 1;
}
const newVideoUrls = videoUrls.filter((url) => {
const key = normalizeMediaUrl(url);
if (!key || existingVideoUrlKeys.has(key)) return false;
existingVideoUrlKeys.add(key);
return true;
});
for (let index = 0; index < newVideoUrls.length; index += 1) {
if (cancelled) return;
const url = newVideoUrls[index];
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
const x = 120 + (existingNodes % 2) * 420;
const y = 120 + Math.floor(existingNodes / 2) * 260;
addNode({
type: "video",
position: { x, y },
size: { width: 380, height: 220 },
data: {
src: url,
status: "completed",
prompt: generationPrompt || "对话生成",
},
connections: [],
});
addedVideoCount += 1;
}
if (addedImageCount > 0 || addedVideoCount > 0) {
const notice = [
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
]
.filter(Boolean)
.join("、");
toast.success(`已将 ${notice} 添加到画布`);
}
}
};
void processNewAssistantMessages();
return () => {
cancelled = true;
};
}, [messages, addNode, nodes, autoNameGeneratedImageNode]);
useEffect(() => {
if (nodes.length < 2) return;
const seenKeys = new Set<string>();
const duplicateNodeIds: string[] = [];
for (const node of nodes) {
const identityKey = getAssistantImageNodeIdentityKey(node);
if (!identityKey) continue;
if (seenKeys.has(identityKey)) {
duplicateNodeIds.push(node.id);
continue;
}
seenKeys.add(identityKey);
}
if (duplicateNodeIds.length === 0) return;
duplicateNodeIds.forEach((nodeId) => deleteNode(nodeId));
console.warn(
"[CanvasChat] Removed duplicate assistant image nodes:",
duplicateNodeIds.length
);
}, [deleteNode, nodes]);
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
useEffect(() => {
if (!messages.length || !nodes.length) return;
const promptByImageUrl = new Map<string, string>();
messages.forEach((message, messageIndex) => {
if (message.role !== "assistant") return;
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
const imageUrls = extractImageUrls(content);
if (!imageUrls.length) return;
const prompt = findNearestUserPrompt(messages, messageIndex);
if (!prompt) return;
imageUrls.forEach((url) => {
if (!promptByImageUrl.has(url)) {
promptByImageUrl.set(url, prompt);
}
});
});
nodes.forEach((node) => {
if (node.type !== "image") return;
const data = (node.data as any) || {};
const src = data.src as string | undefined;
if (!src) return;
if (!isGenericCanvasPrompt(data.prompt)) return;
const prompt = promptByImageUrl.get(src);
if (!prompt) return;
updateNode(node.id, {
data: {
...data,
prompt,
metadata: {
...(data.metadata || {}),
generationPrompt: prompt,
},
},
});
});
}, [messages, nodes, updateNode]);
// 滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false);
// 🆕 Agent模式上下文(画布图片布局)
const buildAutoContext = useCallback(() => {
const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src);
if (imageNodes.length === 0) return "";
const MAX_CONTEXT_IMAGES = 6;
const xs = imageNodes.map(n => n.position.x);
const ys = imageNodes.map(n => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const positionLabel = (node: (typeof imageNodes)[number]) => {
const relX = (node.position.x - minX) / spanX;
const relY = (node.position.y - minY) / spanY;
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
if (horizontal === "中" && vertical === "中") return "中间";
return `${vertical}${horizontal}`;
};
const selectedNodes = selectedImageNodes.length > 0 ? selectedImageNodes : [];
const contextNodes = (selectedNodes.length > 0 ? selectedNodes : imageNodes.slice(-MAX_CONTEXT_IMAGES)).slice(0, MAX_CONTEXT_IMAGES);
const lines = [
`画布图片总数: ${imageNodes.length}`,
selectedNodes.length > 0
? `用户已选中 ${selectedNodes.length} 张图片,优先处理这些`
: `用户未选中图片,默认使用最近 ${contextNodes.length} 张`,
...contextNodes.map((node, index) => {
const label = positionLabel(node);
const metadata = (node.data as any)?.metadata;
const originalWidth = Number(metadata?.originalWidth || 0);
const originalHeight = Number(metadata?.originalHeight || 0);
const originalSize =
originalWidth > 0 && originalHeight > 0
? `,原图分辨率 ${Math.round(originalWidth)}x${Math.round(originalHeight)}`
: "";
const prompt = (node.data as any)?.prompt;
return `图片${index + 1}: ${label}${originalSize}${prompt ? `,描述:${String(prompt).slice(0, 60)}` : ""}`;
}),
"若用户提到左/右/上/下/中等方位,请匹配对应位置的图片。",
];
return lines.join("\n");
}, [nodes, selectedImageNodes]);
const buildContextPayload = useCallback(() => {
const imageNodes = nodes.filter(
(node) =>
node.type === "image" &&
typeof (node.data as any)?.src === "string" &&
String((node.data as any).src).trim().length > 0,
);
if (imageNodes.length === 0) return undefined;
const MAX_CONTEXT_IMAGES = 12;
const selectedIds = new Set(selectedImageNodes.map((node) => node.id));
const prioritizedNodes =
selectedImageNodes.length > 0
? [
...selectedImageNodes,
...imageNodes.filter((node) => !selectedIds.has(node.id)),
]
: imageNodes;
const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES);
const selectedAssetIds = Array.from(
new Set(
selectedImageNodes
.map((node) => {
const assetId = (node.data as any)?.assetId;
return typeof assetId === "string" && assetId.trim() ? assetId.trim() : null;
})
.filter((value): value is string => Boolean(value)),
),
);
return {
source: "canvas" as const,
projectId: rawChatId || undefined,
selectedNodeIds: selectedImageNodes.map((node) => node.id),
selectedAssetIds,
focusPolicy: selectedImageNodes.length > 0 ? ("selected" as const) : ("auto" as const),
canvasImageNodes: contextNodes.map((node) => ({
nodeId: node.id,
assetId:
typeof (node.data as any)?.assetId === "string" && (node.data as any).assetId.trim()
? String((node.data as any).assetId).trim()
: undefined,
url: String((node.data as any)?.src || ""),
title:
typeof (node.data as any)?.title === "string"
? String((node.data as any).title).slice(0, 256)
: undefined,
prompt:
typeof (node.data as any)?.prompt === "string"
? String((node.data as any).prompt).slice(0, 2000)
: undefined,
x: Number(node.position.x || 0),
y: Number(node.position.y || 0),
width: Number(node.size?.width || 0) || undefined,
height: Number(node.size?.height || 0) || undefined,
isSelected: selectedIds.has(node.id),
})),
};
}, [nodes, rawChatId, selectedImageNodes]);
const updateAttachmentAtVideoSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
const slotCount = Math.max(videoFrameSlots.length, 2);
setAttachments((current) => {
const slots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
while (slots.length > 0 && !slots[slots.length - 1]) {
slots.pop();
}
return slots.filter((item): item is PickedObjectAttachment => Boolean(item));
});
},
[videoFrameSlots.length]
);
const updateAttachmentAtAgentSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
const slots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
while (slots.length > 0 && !slots[slots.length - 1]) {
slots.pop();
}
return slots.filter((item): item is PickedObjectAttachment => Boolean(item));
});
},
[agentUploadSlots.length]
);
const fillVideoSlotFromCanvasSelection = useCallback(
(slotIndex: number) => {
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
const attachment = preferredNode
? getCanvasNodeAttachment(preferredNode, `参考图 ${slotIndex + 1}`)
: null;
if (!attachment) return false;
updateAttachmentAtVideoSlot(slotIndex, attachment);
return true;
},
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot]
);
const fillAgentSlotFromCanvasSelection = useCallback(
(slotIndex: number) => {
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
const attachment = preferredNode
? getCanvasNodeAttachment(preferredNode, `智能体图片 ${slotIndex + 1}`)
: null;
if (!attachment) return false;
updateAttachmentAtAgentSlot(slotIndex, attachment);
return true;
},
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtAgentSlot]
);
const handleVideoSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
event.currentTarget.value = "";
return;
}
setVideoSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
updateAttachmentAtVideoSlot(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
assetId,
});
const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
status: "completed",
title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
toast.success(`${slotIndex === 0 ? "首帧" : "尾帧"}上传成功`);
} catch (_error) {
toast.error("图片上传失败");
} finally {
setVideoSlotUploading(null);
event.currentTarget.value = "";
}
},
[addNode, nodes.length, updateAttachmentAtVideoSlot]
);
const handleAgentSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
event.currentTarget.value = "";
return;
}
setAgentSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
updateAttachmentAtAgentSlot(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
assetId,
});
const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
status: "completed",
title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`;
toast.success(`${slotLabel}上传成功`);
} catch (_error) {
toast.error("图片上传失败");
} finally {
setAgentSlotUploading(null);
event.currentTarget.value = "";
}
},
[addNode, agentUploadSlots, nodes.length, updateAttachmentAtAgentSlot]
);
const updateDetailSlotAttachment = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
setDetailSlots((current) => {
const next = [...current];
next[slotIndex] = attachment;
return next;
});
},
[]
);
const fillDetailSlotsFromCanvasSelection = useCallback(
(slotIndex: number) => {
if (slotIndex < 0 || slotIndex >= DETAIL_SLOT_CONFIGS.length) return 0;
if (selectedImageNodes.length === 0) return 0;
const slotRole = DETAIL_SLOT_CONFIGS[slotIndex]?.role;
const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === slotRole ? index : -1
).filter((index) => index >= 0);
const startOffset = sameRoleIndexes.indexOf(slotIndex);
const targetIndexes =
startOffset >= 0 ? sameRoleIndexes.slice(startOffset) : sameRoleIndexes;
if (targetIndexes.length === 0) return 0;
const candidateAttachments = selectedImageNodes
.map((node, index) =>
getCanvasNodeAttachment(
node,
`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材"}-${index + 1}`
)
)
.filter((item): item is PickedObjectAttachment => Boolean(item));
if (candidateAttachments.length === 0) return 0;
let filledCount = 0;
setDetailSlots((current) => {
const next = [...current];
for (let i = 0; i < targetIndexes.length && i < candidateAttachments.length; i += 1) {
const index = targetIndexes[i]!;
next[index] = candidateAttachments[i]!;
filledCount += 1;
}
return next;
});
return filledCount;
},
[getCanvasNodeAttachment, selectedImageNodes]
);
const activateDetailSlotCanvasPicker = useCallback(
(slotIndex: number) => {
const filled = fillDetailSlotsFromCanvasSelection(slotIndex);
if (filled > 0) {
const label = DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材";
toast.success(
filled > 1 ? `已从画布批量填入 ${filled} 张到${label}分组` : `已从画布填入${label}`
);
setDetailSlotPickerIndex(null);
return;
}
setDetailSlotPickerIndex(slotIndex);
const label = DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材";
toast.info(`已选中${label},请在画布中点击图片即可自动填入`);
},
[fillDetailSlotsFromCanvasSelection]
);
useEffect(() => {
if (!isDetailGenerationMode) {
setDetailSlotPickerIndex(null);
return;
}
if (detailSlotPickerIndex === null) return;
if (selectedImageNodes.length === 0) return;
const filled = fillDetailSlotsFromCanvasSelection(detailSlotPickerIndex);
if (filled > 0) {
const label = DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材";
toast.success(
filled > 1 ? `已从画布批量填入 ${filled} 张到${label}分组` : `已从画布填入${label}`
);
setDetailSlotPickerIndex(null);
}
}, [
detailSlotPickerIndex,
fillDetailSlotsFromCanvasSelection,
isDetailGenerationMode,
selectedImageNodes.length,
]);
useEffect(() => {
if (!isDetailGenerationMode) {
setIsDetailWorkbenchCollapsed(false);
}
}, [isDetailGenerationMode]);
const handleDetailSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
event.currentTarget.value = "";
return;
}
setDetailSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = String(data.url || "").trim();
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
if (!imageUrl) {
throw new Error("未获取到图片地址");
}
updateDetailSlotAttachment(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
assetId,
});
toast.success(`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}上传成功`);
} catch (_error) {
toast.error("图片上传失败");
} finally {
setDetailSlotUploading(null);
event.currentTarget.value = "";
}
},
[updateDetailSlotAttachment]
);
const updateLocalDetailArchive = useCallback(
(archiveId: string, patch: Partial<DetailArchive>) => {
setDetailArchives((prev) =>
prev.map((item) =>
item.id === archiveId
? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() }
: item
)
);
},
[]
);
const createDetailArchiveInDatabase = useCallback(
async (archive: DetailArchive): Promise<DetailArchive | null> => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ archive }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success || !result?.data?.id) {
console.error("[CanvasChat] Failed to persist detail archive:", result?.error || response.statusText);
return null;
}
const createdAtValue =
typeof result.data.createdAt === "number"
? result.data.createdAt
: Date.parse(String(result.data.createdAt || ""));
return {
...archive,
id: String(result.data.id),
createdAt: Number.isFinite(createdAtValue) ? createdAtValue : archive.createdAt,
updatedAt: Number.isFinite(createdAtValue) ? createdAtValue : archive.createdAt,
};
} catch (error) {
console.error("[CanvasChat] Failed to persist detail archive:", error);
return null;
}
},
[canvasId]
);
const patchDetailArchiveInDatabase = useCallback(
async (archiveId: string, archive: DetailArchive) => {
try {
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ archiveId, archive }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success) {
console.error(
"[CanvasChat] Failed to update detail archive:",
result?.error || response.statusText
);
return false;
}
return true;
} catch (error) {
console.error("[CanvasChat] Failed to update detail archive:", error);
return false;
}
},
[canvasId]
);
const saveDetailArchiveSnapshot = useCallback(
async (params: {
summary?: string;
imageCount?: number;
coverUrl?: string;
prompt: string;
generationMode: DetailGenerationMode;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
}): Promise<DetailArchive | null> => {
const archiveId = generateUUID();
const promptText = params.prompt.replace(/\s+/g, " ").trim();
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
const demandSeed = String(
params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || ""
)
.replace(/\s+/g, " ")
.trim();
const createdAt = Date.now();
const archiveTitle =
spuSeed && demandSeed
? `${spuSeed}+${demandSeed}`
: spuSeed || demandSeed || params.brief.productName || "AI详情项目";
const archive: DetailArchive = {
id: archiveId,
title: archiveTitle,
prompt: promptText,
generationMode: params.generationMode,
brief: { ...params.brief },
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = params.slots[index];
return slot
? {
url: slot.url,
name: slot.name,
contentType: slot.contentType,
}
: null;
}),
createdAt,
updatedAt: createdAt,
summary: params.summary,
imageCount: params.imageCount,
coverUrl: params.coverUrl,
};
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
const saved = await createDetailArchiveInDatabase(archive);
if (saved?.id) {
setDetailArchives((prev) => {
const next = [saved, ...prev.filter((item) => item.id !== archive.id && item.id !== saved.id)];
return next.slice(0, 30);
});
return saved;
}
return archive;
},
[createDetailArchiveInDatabase]
);
const loadDetailArchive = useCallback((archive: DetailArchive) => {
setIsDetailGenerationMode(true);
setDetailGenerationMode(normalizeDetailGenerationMode(archive.generationMode));
setDetailBrief({ ...archive.brief });
setDetailSlots(
DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = archive.slots[index];
return slot
? {
url: slot.url,
name: slot.name,
contentType: slot.contentType || "image/jpeg",
}
: null;
})
);
setInput(archive.prompt || "");
setDetailSections((prev) => ({
...prev,
required: true,
assets: true,
archives: true,
}));
setDetailSlotPickerIndex(null);
setTimeout(() => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.textContent = archive.prompt || "";
editableDiv.focus();
}
}, 0);
toast.success("已加载历史详情项目,可继续修改后重新生成(会自动存为新存档)");
}, []);
const deleteDetailArchive = useCallback(
async (archiveId: string) => {
if (!archiveId || deletingDetailArchiveId === archiveId) return;
const targetArchive = detailArchives.find((item) => item.id === archiveId);
const targetTitle = targetArchive?.title || "该存档";
if (!window.confirm(`确认删除“${targetTitle}”吗?删除后不可恢复。`)) {
return;
}
setDeletingDetailArchiveId(archiveId);
try {
const response = await fetch(
`/api/canvas/${canvasId}/detail-archives?archiveId=${encodeURIComponent(archiveId)}`,
{ method: "DELETE" }
);
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.success) {
throw new Error(String(result?.error || "删除失败"));
}
setDetailArchives((prev) => prev.filter((item) => item.id !== archiveId));
toast.success("已删除历史存档");
} catch (error) {
console.error("[CanvasChat] Failed to delete detail archive:", error);
toast.error(error instanceof Error ? error.message : "删除历史存档失败");
} finally {
setDeletingDetailArchiveId((current) => (current === archiveId ? null : current));
}
},
[canvasId, deletingDetailArchiveId, detailArchives]
);
const handleDetailGeneration = useCallback(async () => {
if (isDetailGenerating) return;
const brief = normalizedDetailBrief;
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
toast.error("请先补充 AI详情生成必填项:主购买人群、需求场景、核心需求");
return;
}
const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES
.map((index) => detailSlots[index])
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
if (productAttachments.length < 5) {
toast.error("请先上传 5 张产品图后再执行 AI详情生成");
return;
}
const modelAttachment =
DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null;
if (!modelAttachment?.url) {
toast.error("请上传 1 张人物模特图后再执行 AI详情生成");
return;
}
const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES
.map((index) => detailSlots[index])
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
const userText = input
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
const basePrompt = brief.productName
? `${brief.productName}详情页生成`
: "电商详情页生成";
const demandHint = brief.demandType ? `,需求类型:${brief.demandType}` : "";
const detailPrompt =
userText ||
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}${demandHint}`;
const archiveSnapshot = {
prompt: detailPrompt,
generationMode: detailGenerationMode,
brief: { ...detailBrief },
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
const slot = detailSlots[index];
return slot
? {
url: slot.url,
name: slot.name,
contentType: slot.contentType,
}
: null;
}),
};
const detailCount = extractDetailFrameCount(detailPrompt);
const assistantMessageId = generateUUID();
const skillLogicPreview = [
"### AI详情生成",
detailGenerationMode === "agent_layout"
? "当前模式:AGENT排版(画面与文字分层生成)"
: "当前模式:传统生图(整页直接生成)",
"我们将基于《详情升级文案方案》执行以下技能流程:",
"1. 定位卡:锁定人群/场景/触发时刻/风险与期望结果",
"2. 需求拆解:卖点转买点,确定核心需求优先级",
"3. 黄金五屏:核心卖点 -> 差异强化 -> 证据收口",
"4. FABE文案:每屏输出利益/特征/优势/证据",
"5. A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾",
"6. 字体规范:主标题64/700,副标题34/600,正文24/500,全套一致",
"7. 分镜预审:先走平面设计总监+营销总监双审,不通过先重写再放行",
"8. 逐屏质检:每屏先生成再由Agent复检,必要时自动二次修图",
"",
"正在规划分镜提示词...",
].join("\n");
const referenceImageNotes = DETAIL_SLOT_CONFIGS.map((config, index) => {
const current = detailSlots[index];
return `${index + 1}. ${config.label}${current?.name ? `(${current.name})` : ""}`;
});
const modelImageHint = `模特图固定策略:涉及人物场景时必须使用第${DETAIL_MODEL_SLOT_INDEX + 1}张模特图同一人物,不得替换人物。`;
const bundleImageHint =
bundleAttachments.length > 0
? `已上传${bundleAttachments.length}张搭配产品图:必须新增1屏场景化产品搭配展示,突出关联销售。`
: "未上传搭配产品图:不强制搭配展示屏。";
const detailBriefText = [
"【AI详情生成输入】",
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
brief.demandType ? `需求类型:${brief.demandType}` : "",
brief.productName ? `产品:${brief.productName}` : "",
`主购买人群:${brief.targetAudience}`,
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
`需求场景:${brief.usageScenario}`,
brief.triggerMoment ? `触发时刻:${brief.triggerMoment}` : "",
`核心需求:${brief.coreNeed}`,
brief.riskConcern ? `用户顾虑:${brief.riskConcern}` : "",
brief.expectedResult ? `期望结果:${brief.expectedResult}` : "",
brief.coreSellingPoint ? `核心卖点关键词:${brief.coreSellingPoint}` : "",
brief.positioningStatement ? `一句话定位:${brief.positioningStatement}` : "",
brief.userVoice ? `用户原话样本:${brief.userVoice}` : "",
brief.competitorReference ? `竞品对标:${brief.competitorReference}` : "",
brief.priceBand ? `价格带:${brief.priceBand}` : "",
brief.brandTone ? `品牌语气:${brief.brandTone}` : "",
brief.bannedWords ? `禁用表达:${brief.bannedWords}` : "",
`参考图说明:${referenceImageNotes.join(";")}`,
modelImageHint,
bundleImageHint,
`输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`,
`生成模式:${detailGenerationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页出图)"}`,
`字体规范:${DETAIL_TYPOGRAPHY_LOCK.title};${DETAIL_TYPOGRAPHY_LOCK.subtitle};${DETAIL_TYPOGRAPHY_LOCK.body}`,
]
.filter(Boolean)
.join("\n");
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh";
let workingArchive: DetailArchive | null = null;
let completedDetailImageCount = 0;
const generatedImages: Array<{ originalUrl: string; thumbnailUrl: string }> = [];
let detailPlaceholderNodeIds: string[] = [];
const detailPlaceholderSpacing = 24;
const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH;
const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT;
const detailPlaceholderStartX = 120;
const detailPlaceholderStartY = 120;
const getDetailPlaceholderPosition = (index: number) => ({
x: detailPlaceholderStartX,
y:
detailPlaceholderStartY +
index * (detailPlaceholderItemHeight + detailPlaceholderSpacing),
});
const expectedPlaceholderCount =
bundleAttachments.length > 0 ? Math.min(20, detailCount + 1) : detailCount;
const patchDetailPlaceholder = (
index: number,
options: {
label?: string;
status?: "generating" | "completed" | "failed";
src?: string;
prompt?: string;
title?: string;
metadata?: Record<string, unknown>;
} = {}
) => {
const nodeId = detailPlaceholderNodeIds[index];
if (!nodeId) return;
const storeState = useCanvasStore.getState();
const currentNode = storeState.getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
const currentMetadata = ((currentData.metadata as any) || {}) as Record<string, any>;
const currentStatus =
currentData.status === "completed" || currentData.status === "failed"
? currentData.status
: "generating";
const nextStatus = options.status || currentStatus;
updateNode(nodeId, {
size: {
width: detailPlaceholderItemWidth,
height: detailPlaceholderItemHeight,
},
data: {
...currentData,
src: String(options.src ?? currentData.src ?? ""),
status: nextStatus,
prompt: options.prompt ?? currentData.prompt,
title: options.title ?? currentData.title ?? `详情图-${index + 1}`,
metadata: {
...currentMetadata,
detailSectionIndex: index + 1,
detailTargetWidth: DETAIL_TARGET_WIDTH,
detailTargetHeight: DETAIL_TARGET_HEIGHT,
detailGeneratingPlaceholder: nextStatus !== "completed",
detailProgressLabel:
options.label ??
currentMetadata.detailProgressLabel ??
(nextStatus === "failed" ? "生成失败" : "平面设计中..."),
...(options.metadata || {}),
},
},
});
};
const ensureDetailPlaceholders = (count: number, defaultLabel: string) => {
for (let index = detailPlaceholderNodeIds.length; index < count; index += 1) {
const { x, y } = getDetailPlaceholderPosition(index);
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: detailPlaceholderItemWidth, height: detailPlaceholderItemHeight },
data: {
src: "",
status: "generating",
prompt: detailPrompt,
title: `详情图-${index + 1}`,
metadata: {
detailSectionIndex: index + 1,
detailTargetWidth: DETAIL_TARGET_WIDTH,
detailTargetHeight: DETAIL_TARGET_HEIGHT,
detailGeneratingPlaceholder: true,
detailProgressLabel: defaultLabel,
},
},
connections: [],
});
detailPlaceholderNodeIds.push(nodeId);
}
};
const markExtraPlaceholders = (startIndex: number, label: string) => {
for (let index = startIndex; index < detailPlaceholderNodeIds.length; index += 1) {
patchDetailPlaceholder(index, {
status: "failed",
label,
metadata: {
detailSkipped: true,
},
});
}
};
setIsDetailWorkbenchCollapsed(true);
setIsDetailGenerating(true);
setDetailProgress({
status: "running",
stage: "Agent 正在解析需求并规划详情结构...",
current: 0,
total: Math.max(1, detailCount * 2 + 1 + DETAIL_REVIEW_COUNT),
});
ensureDetailPlaceholders(expectedPlaceholderCount, "需求解析与分镜规划中...");
try {
workingArchive = await saveDetailArchiveSnapshot({
summary: `进行中:已完成 0/${detailCount} 张详情图`,
imageCount: 0,
coverUrl: undefined,
prompt: archiveSnapshot.prompt,
generationMode: archiveSnapshot.generationMode,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
const detailReferenceAttachments = DETAIL_SLOT_CONFIGS.map((config, index) => {
const current = detailSlots[index];
if (!current?.url) return null;
return {
...current,
name: `${config.label}${current.name ? `-${current.name}` : ""}`,
};
}).filter((item): item is PickedObjectAttachment => Boolean(item));
const userParts: any[] = detailReferenceAttachments.map((item) => ({
type: "file",
mediaType: item.contentType,
name: item.name,
url: item.url,
}));
userParts.push({
type: "text",
text: `${detailPrompt}\n\n${detailBriefText}`,
});
const userMessage: any = {
id: generateUUID(),
role: "user",
parts: userParts,
};
const assistantPendingMessage: any = {
id: assistantMessageId,
role: "assistant",
parts: [
{
type: "text",
text: skillLogicPreview,
},
],
};
setMessages((prev) => [...prev, userMessage, assistantPendingMessage]);
setInput("");
setAttachments([]);
clearSelection();
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = "";
}
const planResponse = await fetch("/api/canvas/detail-plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: detailPrompt,
count: detailCount,
generationMode: detailGenerationMode,
referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 12),
referenceImageNotes,
modelImageUrl: modelAttachment.url,
bundleImageUrls: bundleAttachments.map((item) => item.url),
outputLanguage,
brief,
}),
});
const planResult = await planResponse.json();
if (!planResponse.ok || !planResult?.success) {
throw new Error(planResult?.error || "详情分镜规划失败");
}
const planData = (planResult.data || {}) as DetailPlanResponse;
const activeDetailGenerationMode = normalizeDetailGenerationMode(
planData.generationMode || detailGenerationMode
);
const perScreenSteps = activeDetailGenerationMode === "agent_layout" ? 1 : 2;
const prompts = Array.isArray(planData.prompts)
? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean)
: [];
if (prompts.length === 0) {
throw new Error("未获取到有效分镜提示词");
}
const screenSpecs = Array.isArray(planData.screens) ? planData.screens : [];
const reviewPlans = buildDetailReviewPlans({
brief,
summary: planData.summary,
language: outputLanguage,
count: DETAIL_REVIEW_COUNT,
});
const totalSteps = prompts.length * perScreenSteps + 1 + reviewPlans.length;
const qualityIssues = Array.isArray(planData.qualityChecks?.issues)
? planData.qualityChecks?.issues.filter(Boolean)
: [];
const preflightIssues = Array.isArray(planData.preflight?.issues)
? planData.preflight?.issues.filter(Boolean)
: [];
const preflightDesignScore = Number(planData.preflight?.designDirectorScore || 0);
const preflightMarketingScore = Number(planData.preflight?.marketingDirectorScore || 0);
const preflightPassed = planData.preflight?.passed !== false;
setDetailProgress({
status: "running",
stage:
planData.qualityChecks?.passed === false
? `分镜预审未通过:${qualityIssues[0] || "已自动切换为稳定方案"}`
: preflightPassed
? `双总监预审通过(设计${preflightDesignScore || "--"} / 营销${preflightMarketingScore || "--"}),开始逐屏生成`
: `双总监预审提示:${preflightIssues[0] || "已自动重写分镜后放行"}`,
current: 1,
total: totalSteps,
});
const modelForGeneration =
selectedModelId !== "auto"
? selectedModelId
: siteSettings?.canvasDetailDefaultModelId ||
siteSettings?.canvasAutoModeDefaultModelId ||
siteSettings?.canvasDefaultModelId ||
undefined;
ensureDetailPlaceholders(prompts.length, "需求解析与分镜规划中...");
if (detailPlaceholderNodeIds.length > prompts.length) {
markExtraPlaceholders(prompts.length, "本轮未使用");
}
const isAgentLayoutMode = activeDetailGenerationMode === "agent_layout";
const titleFontSize = Math.max(
36,
Math.min(
80,
extractFirstNumber(planData.globalStyle?.typography?.title || "", 64)
)
);
const subtitleFontSize = Math.max(
24,
Math.min(
50,
extractFirstNumber(planData.globalStyle?.typography?.subtitle || "", 34)
)
);
const bodyFontSize = Math.max(
18,
Math.min(
36,
extractFirstNumber(planData.globalStyle?.typography?.body || "", 24)
)
);
const titleFontWeight = extractFontWeight(planData.globalStyle?.typography?.title || "", "700");
const subtitleFontWeight = extractFontWeight(
planData.globalStyle?.typography?.subtitle || "",
"600"
);
const bodyFontWeight = extractFontWeight(planData.globalStyle?.typography?.body || "", "500");
const createAgentLayoutTextLayers = (params: {
nodeId: string;
screenIndex: number;
screen?: DetailPlanScreenSpec;
}) => {
if (!isAgentLayoutMode) return;
const storeState = useCanvasStore.getState();
const imageNode = storeState.getNode(params.nodeId);
if (!imageNode) return;
const x = Number(imageNode.position?.x || 0);
const y = Number(imageNode.position?.y || 0);
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
const safeLeft = Math.round(width * 0.07);
const textWidth = Math.max(240, Math.round(width - safeLeft * 2));
const screen = params.screen;
const titleText = String(screen?.title || "").trim();
const subtitleText = String(screen?.subtitle || "").trim();
const bodyText = String(screen?.copy || "").trim();
if (titleText) {
addNode({
type: "text",
position: { x: x + safeLeft, y: y + 72 },
size: { width: textWidth, height: 140 },
data: {
content: titleText,
fontSize: params.screenIndex === 0 ? titleFontSize : Math.round(titleFontSize * 0.86),
fontWeight: titleFontWeight,
color: "#111827",
lineHeight: 1.15,
textAlign: "left",
transparent: true,
},
connections: [],
parentId: params.nodeId,
});
}
if (subtitleText) {
addNode({
type: "text",
position: { x: x + safeLeft, y: y + 214 },
size: { width: textWidth, height: 120 },
data: {
content: subtitleText,
fontSize: subtitleFontSize,
fontWeight: subtitleFontWeight,
color: "#374151",
lineHeight: 1.22,
textAlign: "left",
transparent: true,
},
connections: [],
parentId: params.nodeId,
});
}
if (bodyText) {
addNode({
type: "text",
position: { x: x + safeLeft, y: y + 304 },
size: { width: textWidth, height: 260 },
data: {
content: bodyText,
fontSize: bodyFontSize,
fontWeight: bodyFontWeight,
color: "#4b5563",
lineHeight: 1.38,
textAlign: "left",
transparent: true,
},
connections: [],
parentId: params.nodeId,
});
}
};
for (let index = 0; index < prompts.length; index += 1) {
const screen = screenSpecs[index];
patchDetailPlaceholder(index, {
label: "分镜规划完成",
status: "generating",
prompt: String(screen?.visualPrompt || prompts[index] || "").trim(),
title: `详情图-${index + 1}`,
metadata: {
detailTitle: screen?.title || "",
detailSubtitle: screen?.subtitle || "",
detailScreenType: screen?.screenType || "",
detailLayoutNote: screen?.layoutNote || "",
detailPlanSummary: planData.summary || "",
detailGlobalStyle: planData.globalStyle || null,
detailPreflight: planData.preflight || null,
detailGenerationMode: activeDetailGenerationMode,
detailGeneratingPlaceholder: true,
},
});
}
const preflightLabel = planData.preflight?.rewriteApplied
? "双总监预审重写完成"
: "双总监预审通过";
for (let index = 0; index < prompts.length; index += 1) {
patchDetailPlaceholder(index, {
label: preflightLabel,
status: "generating",
});
}
for (let index = 0; index < prompts.length; index += 1) {
patchDetailPlaceholder(index, {
label: `排队待生成 ${index + 1}/${prompts.length}`,
status: "generating",
});
}
for (let index = 0; index < prompts.length; index += 1) {
const screen = screenSpecs[index];
const useModelForScene = Boolean(screen?.useModel);
const bundleShowcase = Boolean((screen as any)?.bundleShowcase);
const bundleReason = String((screen as any)?.bundleReason || "").trim();
const promptText =
String(screen?.visualPrompt || "").trim() || prompts[index]!;
const generationStepCurrent = 2 + index * perScreenSteps;
const reviewStepCurrent = generationStepCurrent + 1;
patchDetailPlaceholder(index, {
label: `主图首稿生成中 ${index + 1}/${prompts.length}`,
status: "generating",
prompt: promptText,
});
setDetailProgress({
status: "running",
stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`,
current: generationStepCurrent,
total: totalSteps,
});
const globalStyle = planData.globalStyle;
const styleDirective = [
globalStyle?.designLanguage ? `全局风格:${globalStyle.designLanguage}` : "",
globalStyle?.typography?.fontFamily
? `字体家族统一:${globalStyle.typography.fontFamily}`
: "",
globalStyle?.typography?.title ? `标题规范:${globalStyle.typography.title}` : "",
globalStyle?.typography?.subtitle
? `副标题规范:${globalStyle.typography.subtitle}`
: "",
globalStyle?.typography?.body ? `正文规范:${globalStyle.typography.body}` : "",
globalStyle?.layout?.grid ? `布局栅格:${globalStyle.layout.grid}` : "",
globalStyle?.layout?.safeArea ? `安全区:${globalStyle.layout.safeArea}` : "",
globalStyle?.layout?.rhythm ? `留白节奏:${globalStyle.layout.rhythm}` : "",
globalStyle?.palette ? `配色:${globalStyle.palette}` : "",
`固定字体锁定:${DETAIL_TYPOGRAPHY_LOCK.title}`,
`固定副标题锁定:${DETAIL_TYPOGRAPHY_LOCK.subtitle}`,
`固定正文锁定:${DETAIL_TYPOGRAPHY_LOCK.body}`,
`固定排版安全区:${DETAIL_TYPOGRAPHY_LOCK.safeArea}`,
`固定留白节奏:${DETAIL_TYPOGRAPHY_LOCK.rhythm}`,
]
.filter(Boolean)
.join(";");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: isAgentLayoutMode
? `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏画面层,随后自动渲染可编辑文字层...`
: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`,
},
],
}
: message,
),
);
const generationPrompt = [
promptText,
styleDirective ? `全局样式约束:${styleDirective}` : "",
screen?.screenType
? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)`
: "",
index === 0
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
: "",
useModelForScene
? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。`
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
bundleShowcase
? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。"
: "",
bundleReason ? `搭配依据:${bundleReason}` : "",
isAgentLayoutMode
? "生成策略:仅生成画面层(产品、光影、材质、场景),不要生成任何文字、数字、Logo、标语、水印。"
: screen?.title
? `本屏标题:${screen.title}`
: "",
isAgentLayoutMode ? "" : screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "",
isAgentLayoutMode ? "" : screen?.copy ? `本屏文案说明:${screen.copy}` : "",
screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "",
screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "",
screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "",
screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "",
screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "",
`输出为竖版电商详情图,尺寸固定 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素。`,
isAgentLayoutMode
? "硬性约束:禁止输出任何可见文字或字符,排版文字由后续程序化图层渲染。"
: "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。",
]
.filter(Boolean)
.join("\n\n");
const styleAnchorUrl =
index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null;
const generationReferenceImagesBase = [
...productAttachments.map((item) => item.url),
...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []),
...(useModelForScene ? [modelAttachment.url] : []),
...(styleAnchorUrl ? [styleAnchorUrl] : []),
].filter(Boolean);
const generateDetailFrame = async (
promptOverride: string,
extraReferenceImages: string[] = []
): Promise<{ originalUrl: string; thumbnailUrl: string }> => {
const generationReferenceImages = [
...generationReferenceImagesBase,
...extraReferenceImages,
].filter(Boolean);
const result = await requestCanvasImageGeneration({
prompt: promptOverride,
model: modelForGeneration,
aspectRatio: "9:16",
imageSize: imageParams.imageSize,
imageCount: 1,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: generationReferenceImages,
});
if (!Array.isArray(result?.images) || result.images.length === 0) {
throw new Error(`第 ${index + 1} 张生成失败`);
}
const generatedImage = result.images[0];
return {
originalUrl: String(generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "").trim(),
thumbnailUrl: String(generatedImage?.thumbnailUrl || generatedImage?.originalUrl || "").trim(),
};
};
const firstPassImage = await generateDetailFrame(generationPrompt);
let finalGenerated = firstPassImage;
let finalGenerationPrompt = generationPrompt;
let screenReviewResult: DetailScreenReviewResult | null = null;
if (!isAgentLayoutMode) {
patchDetailPlaceholder(index, {
label: `双总监审稿中 ${index + 1}/${prompts.length}`,
status: "generating",
});
setDetailProgress({
status: "running",
stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
current: reviewStepCurrent,
total: totalSteps,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
},
],
}
: message,
),
);
try {
const reviewResponse = await fetch("/api/canvas/detail-review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outputLanguage,
imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
referenceImages: generationReferenceImagesBase.slice(0, 6),
brief,
globalStyle: planData.globalStyle,
screen: {
index: index + 1,
title: screen?.title || "",
subtitle: screen?.subtitle || "",
copy: screen?.copy || "",
layoutNote: screen?.layoutNote || "",
visualPrompt: generationPrompt,
screenType: screen?.screenType || "",
},
}),
});
const reviewResult = await reviewResponse.json().catch(() => ({}));
if (reviewResponse.ok && reviewResult?.success) {
screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult;
}
} catch (reviewError) {
console.error("[CanvasChat] Detail screen review failed:", reviewError);
}
const shouldRegenerate =
screenReviewResult?.pass === false &&
typeof screenReviewResult?.revisedPrompt === "string" &&
screenReviewResult.revisedPrompt.trim().length > 20;
if (shouldRegenerate) {
patchDetailPlaceholder(index, {
label: `二次优化生成中 ${index + 1}/${prompts.length}`,
status: "generating",
});
const issueHint = Array.isArray(screenReviewResult?.issues)
? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";")
: "";
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`,
},
],
}
: message,
),
);
for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) {
try {
const revisedPrompt = String(screenReviewResult?.revisedPrompt || "").trim();
const regenerated = await generateDetailFrame(revisedPrompt, [
firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
]);
finalGenerated = regenerated;
finalGenerationPrompt = revisedPrompt;
break;
} catch (regenError) {
if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) {
console.error("[CanvasChat] Detail second-pass generation failed:", regenError);
}
}
}
}
} else {
patchDetailPlaceholder(index, {
label: `排版层渲染中 ${index + 1}/${prompts.length}`,
status: "generating",
});
}
generatedImages.push(finalGenerated);
completedDetailImageCount = generatedImages.length;
if (workingArchive?.id) {
const progressSummary = `进行中:已完成 ${completedDetailImageCount}/${prompts.length} 张详情图`;
const progressCoverUrl = generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl;
const nextArchive: DetailArchive = {
...workingArchive,
summary: progressSummary,
imageCount: completedDetailImageCount,
coverUrl: progressCoverUrl,
};
workingArchive = nextArchive;
updateLocalDetailArchive(nextArchive.id, {
summary: progressSummary,
imageCount: completedDetailImageCount,
coverUrl: progressCoverUrl,
});
await patchDetailArchiveInDatabase(nextArchive.id, nextArchive);
}
const dimensions = await getImageDimensions(
finalGenerated.originalUrl || finalGenerated.thumbnailUrl
);
const targetNodeId = detailPlaceholderNodeIds[index];
if (targetNodeId) {
const storeState = useCanvasStore.getState();
const currentNode = storeState.getNode(targetNodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(targetNodeId, {
size: {
width: DETAIL_TARGET_WIDTH,
height: DETAIL_TARGET_HEIGHT,
},
data: {
...currentData,
src: finalGenerated.originalUrl || finalGenerated.thumbnailUrl,
status: "completed",
prompt: promptText,
title: `详情图-${index + 1}`,
metadata: {
...(currentData.metadata || {}),
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: finalGenerationPrompt,
detailSectionIndex: index + 1,
detailPlanSummary: planData.summary || "",
detailTargetWidth: DETAIL_TARGET_WIDTH,
detailTargetHeight: DETAIL_TARGET_HEIGHT,
detailTitle: screen?.title || "",
detailSubtitle: screen?.subtitle || "",
detailScreenType: screen?.screenType || "",
detailLayoutNote: screen?.layoutNote || "",
detailUseModel: useModelForScene,
detailModelReason: screen?.modelReason || "",
detailBundleShowcase: bundleShowcase,
detailBundleReason: bundleReason,
detailGlobalStyle: planData.globalStyle || null,
detailGenerationMode: activeDetailGenerationMode,
detailScreenReview: screenReviewResult || null,
detailGeneratingPlaceholder: false,
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
},
},
});
createAgentLayoutTextLayers({
nodeId: targetNodeId,
screenIndex: index,
screen,
});
}
}
if (generatedImages.length !== prompts.length) {
throw new Error(
`详情主图未完整生成:计划 ${prompts.length} 屏,实际完成 ${generatedImages.length} 屏`
);
}
const reviewGroups: DetailReviewGroup[] = [];
const reviewFailures: string[] = [];
const detailStartX = 120;
const detailStartY = 120;
const detailSpacing = 24;
const reviewStartY =
detailStartY + prompts.length * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
const reviewPlan = reviewPlans[reviewIndex]!;
const reviewStepCurrent = prompts.length * 2 + 2 + reviewIndex;
setDetailProgress({
status: "running",
stage: `正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}`,
current: reviewStepCurrent,
total: totalSteps,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n详情图已完成,正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}...`,
},
],
}
: message,
),
);
const reviewPrompt = [
reviewPlan.imagePrompt,
`评价文本:${reviewPlan.reviewText}`,
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
"人物规则:买家秀必须使用真实买家视角,不得复用上传的官方模特图形象。",
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图要保持同一叙事主题但角度不同。`,
"图片内容要看得见产品主体和真实使用痕迹,适合放在详情页评价区。",
]
.filter(Boolean)
.join("\n\n");
const reviewReferenceImages = [
...productAttachments.map((item) => item.url),
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
].filter(Boolean);
const reviewResult = await requestCanvasImageGeneration({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageSize: imageParams.imageSize,
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
});
const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
if (reviewImagesRaw.length === 0) {
reviewFailures.push(`评价${reviewIndex + 1}生成失败`);
continue;
}
const reviewImages = reviewImagesRaw
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
.map((item: any) => ({
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
}))
.filter((item: { originalUrl: string; thumbnailUrl: string }) => Boolean(item.originalUrl));
if (reviewImages.length === 0) {
reviewFailures.push(`评价${reviewIndex + 1}未返回有效图片`);
continue;
}
reviewGroups.push({
reviewText: reviewPlan.reviewText,
images: reviewImages,
});
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
const x =
detailStartX +
imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
const y =
reviewStartY +
reviewIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
addNode({
type: "image",
position: { x, y },
size: {
width: DETAIL_REVIEW_IMAGE_WIDTH,
height: DETAIL_REVIEW_IMAGE_HEIGHT,
},
data: {
src: image.originalUrl || image.thumbnailUrl,
status: "completed",
prompt: reviewPlan.imagePrompt,
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
note: reviewPlan.reviewText,
metadata: {
detailReviewIndex: reviewPlan.index,
detailReviewTitle: reviewPlan.title,
detailReviewText: reviewPlan.reviewText,
detailReviewImageIndex: imageIndex + 1,
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
},
},
connections: [],
});
});
}
const finalMarkdown = buildDetailResultMarkdown({
generationMode: activeDetailGenerationMode,
summary: planData.summary,
images: generatedImages,
prompts,
skillFlow: planData.skillFlow,
qualityChecks: planData.qualityChecks,
globalStyle: planData.globalStyle,
screens: screenSpecs,
reviewGroups,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: finalMarkdown,
},
],
}
: message,
),
);
setDetailProgress({
status: "done",
stage:
reviewFailures.length > 0
? `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,评价晒图部分失败 ${reviewFailures.length} 条`
: `详情主图完成 ${generatedImages.length}/${prompts.length} 屏 + ${reviewGroups.length} 条评价晒图`,
current: totalSteps,
total: totalSteps,
});
const finalSummary =
planData.summary ||
(reviewFailures.length > 0
? `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,评价晒图部分失败 ${reviewFailures.length} 条`
: `详情主图完成 ${generatedImages.length}/${prompts.length} 屏 + ${reviewGroups.length} 条评价晒图`);
if (workingArchive?.id) {
const finalArchive: DetailArchive = {
...workingArchive,
summary: finalSummary,
imageCount: generatedImages.length,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
};
workingArchive = finalArchive;
updateLocalDetailArchive(finalArchive.id, {
summary: finalArchive.summary,
imageCount: finalArchive.imageCount,
coverUrl: finalArchive.coverUrl,
});
await patchDetailArchiveInDatabase(finalArchive.id, finalArchive);
} else {
await saveDetailArchiveSnapshot({
summary: finalSummary,
imageCount: generatedImages.length,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
prompt: archiveSnapshot.prompt,
generationMode: archiveSnapshot.generationMode,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
}
if (reviewFailures.length > 0) {
toast.warning(
`详情主图完成 ${generatedImages.length}/${prompts.length} 屏,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
);
} else {
toast.success(
`AI详情生成完成:详情主图 ${generatedImages.length}/${prompts.length} 屏,评价晒图 ${reviewGroups.length} 条`
);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : "AI详情生成失败";
if (detailPlaceholderNodeIds.length > 0) {
for (let index = completedDetailImageCount; index < detailPlaceholderNodeIds.length; index += 1) {
const nodeId = detailPlaceholderNodeIds[index];
if (!nodeId) continue;
const storeState = useCanvasStore.getState();
const currentNode = storeState.getNode(nodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
if (currentData.status === "completed") continue;
updateNode(nodeId, {
data: {
...currentData,
src: String(currentData.src || ""),
status: "failed",
metadata: {
...(currentData.metadata || {}),
detailGeneratingPlaceholder: false,
detailFailedReason: errMsg,
},
},
});
}
}
if (workingArchive?.id) {
const failedSummary = `异常中断:已完成 ${completedDetailImageCount} 张详情图,错误:${errMsg}`;
const failedArchive: DetailArchive = {
...workingArchive,
summary: failedSummary,
imageCount: completedDetailImageCount,
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
};
updateLocalDetailArchive(failedArchive.id, {
summary: failedSummary,
imageCount: completedDetailImageCount,
coverUrl: failedArchive.coverUrl,
});
void patchDetailArchiveInDatabase(failedArchive.id, failedArchive);
}
setDetailProgress((prev) => ({
status: "error",
stage: errMsg,
current: prev.current,
total: prev.total,
}));
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成失败\n${errMsg}`,
},
],
}
: message,
),
);
toast.error(errMsg);
} finally {
setIsDetailGenerating(false);
}
}, [
addNode,
clearSelection,
detailBrief,
detailGenerationMode,
detailSlots,
imageParams.negativePrompt,
input,
isDetailGenerating,
normalizedDetailBrief,
patchDetailArchiveInDatabase,
selectedModelId,
saveDetailArchiveSnapshot,
setMessages,
siteSettings?.canvasAutoModeDefaultModelId,
siteSettings?.canvasDetailDefaultModelId,
siteSettings?.canvasDefaultModelId,
updateLocalDetailArchive,
]);
const handleGenerateMoreDetailReviews = useCallback(async () => {
if (isDetailGenerating) return;
if (!hasGeneratedDetailImages) {
toast.error("请先完成一轮AI详情生成,再追加评价晒图");
return;
}
const brief = normalizedDetailBrief;
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
toast.error("请先补充 AI详情生成必填项后再追加评价晒图");
return;
}
const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES
.map((index) => detailSlots[index])
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
if (productAttachments.length < 5) {
toast.error("请先补齐5张产品图,再追加评价晒图");
return;
}
const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES
.map((index) => detailSlots[index])
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
const languageSeed = [
brief.productName,
brief.targetAudience,
brief.usageScenario,
brief.coreNeed,
]
.filter(Boolean)
.join(" ");
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(languageSeed) ? "en" : "zh";
const reviewPlans = buildDetailReviewPlans({
brief,
language: outputLanguage,
count: DETAIL_REVIEW_REGEN_COUNT,
});
if (reviewPlans.length === 0) {
toast.error("未生成可用的评价方案");
return;
}
const assistantMessageId = generateUUID();
const modelForGeneration =
selectedModelId !== "auto"
? selectedModelId
: siteSettings?.canvasDetailDefaultModelId ||
siteSettings?.canvasAutoModeDefaultModelId ||
siteSettings?.canvasDefaultModelId ||
undefined;
const existingImageBottom = nodes
.filter((node) => node.type === "image")
.reduce((max, node) => {
const currentBottom = Number(node.position?.y || 0) + Number(node.size?.height || 0);
return Math.max(max, currentBottom);
}, 120);
const reviewStartY = existingImageBottom + 120;
const reviewStartX = 120;
const reviewGroups: DetailReviewGroup[] = [];
const reviewFailures: string[] = [];
const batchId = generateUUID();
setIsDetailGenerating(true);
setDetailProgress({
status: "running",
stage: "正在追加买家评价与晒图...",
current: 0,
total: reviewPlans.length,
});
setMessages((prev) => [
...prev,
{
id: assistantMessageId,
role: "assistant",
parts: [
{
type: "text",
text: `### 买家评价与晒图\n正在生成新的 ${reviewPlans.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张...`,
},
],
} as any,
]);
try {
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
const reviewPlan = reviewPlans[reviewIndex]!;
setDetailProgress({
status: "running",
stage: `正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条评价晒图`,
current: reviewIndex + 1,
total: reviewPlans.length,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### 买家评价与晒图\n正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条...`,
},
],
}
: message
)
);
const reviewPrompt = [
reviewPlan.imagePrompt,
`评价文本:${reviewPlan.reviewText}`,
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
"人物规则:买家秀必须使用真实买家视角,不得复用上传的官方模特图形象。",
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图保持同一叙事主题但角度不同。`,
"图片内容需要清晰展示产品主体与使用场景,可直接用于详情页评价区。",
]
.filter(Boolean)
.join("\n\n");
const reviewReferenceImages = [
...productAttachments.map((item) => item.url),
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
].filter(Boolean);
const reviewResult = await requestCanvasImageGeneration({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageSize: imageParams.imageSize,
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
});
const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
if (reviewImagesRaw.length === 0) {
reviewFailures.push(`第${reviewIndex + 1}条生成失败`);
continue;
}
const reviewImages = reviewImagesRaw
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
.map((item: any) => ({
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
}))
.filter((item: { originalUrl: string; thumbnailUrl: string }) =>
Boolean(item.originalUrl)
);
if (reviewImages.length === 0) {
reviewFailures.push(`第${reviewIndex + 1}条无有效图片`);
continue;
}
const rowIndex = reviewGroups.length;
reviewGroups.push({
reviewText: reviewPlan.reviewText,
images: reviewImages,
});
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
const x = reviewStartX + imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
const y = reviewStartY + rowIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
addNode({
type: "image",
position: { x, y },
size: {
width: DETAIL_REVIEW_IMAGE_WIDTH,
height: DETAIL_REVIEW_IMAGE_HEIGHT,
},
data: {
src: image.originalUrl || image.thumbnailUrl,
status: "completed",
prompt: reviewPlan.imagePrompt,
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
note: reviewPlan.reviewText,
metadata: {
detailReviewIndex: reviewPlan.index,
detailReviewTitle: reviewPlan.title,
detailReviewText: reviewPlan.reviewText,
detailReviewImageIndex: imageIndex + 1,
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
detailReviewBatchId: batchId,
detailReviewBatchType: "manual-append",
},
},
connections: [],
});
});
}
const finalText = buildDetailReviewAppendMarkdown({
groups: reviewGroups,
failedCount: reviewFailures.length,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: finalText,
},
],
}
: message
)
);
setDetailProgress({
status: "done",
stage:
reviewFailures.length > 0
? `已追加 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
: `已追加 ${reviewGroups.length} 条买家评价晒图`,
current: reviewPlans.length,
total: reviewPlans.length,
});
if (reviewFailures.length > 0) {
toast.warning(`追加成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`);
} else {
toast.success(`已追加 ${reviewGroups.length} 条买家评价晒图`);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : "追加买家评价与晒图失败";
setDetailProgress({
status: "error",
stage: errMsg,
current: 0,
total: reviewPlans.length,
});
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### 买家评价与晒图追加失败\n${errMsg}`,
},
],
}
: message
)
);
toast.error(errMsg);
} finally {
setIsDetailGenerating(false);
}
}, [
addNode,
detailSlots,
hasGeneratedDetailImages,
imageParams.negativePrompt,
isDetailGenerating,
nodes,
normalizedDetailBrief,
selectedModelId,
setMessages,
siteSettings?.canvasAutoModeDefaultModelId,
siteSettings?.canvasDetailDefaultModelId,
siteSettings?.canvasDefaultModelId,
]);
const ensureImagePointsBeforeSend = useCallback(() => {
if (isVideoModel || activeComposerMode === "agent") return true;
if (membershipSummary?.isHighVip) return true;
const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true;
const blockOnInsufficient =
membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false;
const unitCost = Math.max(
0,
Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0))
);
if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) {
return true;
}
const requestedImageCount = Math.max(
1,
Math.round(Number(imageParams?.imageCount || 1))
);
const expectedCost = unitCost * requestedImageCount;
const currentBalance = Math.max(
0,
Number(membershipSummary?.summary?.currentCredits ?? 0)
);
if (currentBalance >= expectedCost) {
return true;
}
setInsufficientPointsState({
balance: currentBalance,
expectedCost,
unitCost,
imageCount: requestedImageCount,
});
toast.error("积分不足,请先充值后再生图");
return false;
}, [
activeComposerMode,
currentModel?.imageGenConfig?.creditCostPerImage,
imageParams?.imageCount,
isVideoModel,
membershipSummary?.isHighVip,
membershipSummary?.pointsSystemConfig?.blockOnInsufficient,
membershipSummary?.pointsSystemConfig?.enabled,
membershipSummary?.summary?.currentCredits,
]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
const currentSendGuard = getSendGuard(activeChatId);
if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") {
return;
}
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
setSendGuard(activeChatId, {
inFlight: true,
signature: `detail:${activeChatId}:${Date.now()}`,
timestamp: Date.now(),
});
try {
await handleDetailGeneration();
} finally {
releaseSendGuard(activeChatId);
}
return;
}
if (!input.trim() && attachments.length === 0 && !selectedSkillTag) return;
const effectiveModelId = currentModelIdRef.current || selectedModelId;
if (!effectiveModelId) {
toast.error("请选择一个模型");
return;
}
if (!ensureImagePointsBeforeSend()) {
return;
}
// 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致)
const parts: any[] = [];
let effectiveAttachments = [...attachments];
if (hasAgentUploadSlots) {
if (effectiveAttachments.length > agentRequiredUploadCount) {
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
}
if (effectiveAttachments.length < agentRequiredUploadCount) {
toast.error("当前智能体需要上传 3 张图片:产品图1、产品图2、模特图");
return;
}
}
const videoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
: null;
if (videoImageLimit) {
if (videoImageLimit.max === 0) {
effectiveAttachments = [];
} else if (effectiveAttachments.length > videoImageLimit.max) {
effectiveAttachments = effectiveAttachments.slice(0, videoImageLimit.max);
}
if (effectiveAttachments.length < videoImageLimit.min) {
toast.error(
videoImageLimit.min === 1
? "当前视频模式需要至少 1 张参考图"
: "当前视频模式需要 2 张首尾帧图片"
);
return;
}
}
// 🆕 检查是否有标注的图片
const annotatedNodes = selectedImageNodes.filter(node => (node.data as any)?.hasAnnotations);
const hasAnnotatedImages = annotatedNodes.length > 0;
// 添加文件部分(作为 file 类型的 part)
// 🆕 收集物品信息用于构建隐藏提示
const pickedObjectInfos: string[] = [];
effectiveAttachments.forEach((a) => {
const att = a as PickedObjectAttachment;
parts.push({
type: "file" as const,
mediaType: a.contentType,
name: a.name,
url: a.url,
assetId: a.assetId,
hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注
pickedObject: att.pickedObject, // 🆕 包含物品信息
});
// 收集物品信息
if (att.pickedObject) {
const { objectName, objectPosition, boundingBox } = att.pickedObject;
pickedObjectInfos.push(
`【物品:${objectName}】位置:${objectPosition},边界框:(${Math.round(boundingBox.x * 100)}%, ${Math.round(boundingBox.y * 100)}%) - (${Math.round((boundingBox.x + boundingBox.width) * 100)}%, ${Math.round((boundingBox.y + boundingBox.height) * 100)}%)`
);
}
});
// 添加文本部分(用户可见)
let textContent = input
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
if (textContent && effectiveAttachments.length > 0) {
let strippedText = textContent;
for (const attachment of effectiveAttachments) {
const fullName = (attachment.name || "").trim();
const shortName = fullName.length > 10 ? `${fullName.substring(0, 8)}...` : fullName;
for (const candidate of [fullName, shortName]) {
if (!candidate) continue;
if (strippedText.startsWith(candidate)) {
strippedText = strippedText.slice(candidate.length).trimStart();
}
}
}
textContent = strippedText || textContent;
}
if (!textContent && effectiveAttachments.length > 0) {
textContent = "请处理这张图片";
}
if (!textContent && hasAgentUploadSlots && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」智能体配置顺序处理产品图1、产品图2和模特图。`;
}
if (!textContent && selectedSkillTag) {
textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`;
}
// 🆕 隐藏提示词(不在 UI 中展示)
const hiddenSegments: string[] = [];
if (selectedSkillTag?.prompt) {
hiddenSegments.push(
`【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}`
);
}
if (selectedAgentPreset?.type === "text" && selectedAgentPreset.slots?.length) {
const presetPrompt = selectedAgentPreset.slots
.map((slot, index) => {
const title = String(slot.title || slot.uploadLabel || `模板${index + 1}`).trim();
const promptText = String(slot.promptTemplate || "").trim();
if (!promptText) return "";
return `【智能体:${selectedAgentPreset.name}|${title}】\n${promptText}`;
})
.filter(Boolean)
.join("\n\n");
if (presetPrompt) {
hiddenSegments.push(presetPrompt);
}
}
if (pickedObjectInfos.length > 0) {
const objectsPrompt = pickedObjectInfos.join('\n');
hiddenSegments.push(
`【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`
);
console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个');
}
if (hasAnnotatedImages) {
hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】");
console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示');
}
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
hiddenSegments.push(
[
"【AI详情生成模式】",
"你正在处理电商详情页任务,请先规划分镜结构,再执行生图。",
"要求:保持同一产品外观一致、风格一致、文案排版一致,按详情页叙事顺序输出结果。",
].join("\n")
);
}
const hiddenPrompt = hiddenSegments.join('\n\n').trim();
hiddenPromptRef.current = hiddenPrompt || null;
const autoContext = buildAutoContext();
autoContextRef.current = autoContext ? autoContext : null;
if (textContent) {
parts.push({ type: "text" as const, text: textContent });
}
// 若 ref 为空才回填,避免覆盖“刚切换但状态尚未刷新的新模型值”
if (!currentModelIdRef.current) {
currentModelIdRef.current = effectiveModelId;
}
const normalizedAttachmentKeys = effectiveAttachments
.map((attachment) => normalizeMediaUrl(attachment.url))
.filter(Boolean)
.sort();
const sendSignature = JSON.stringify({
chatId: activeChatId,
model: effectiveModelId,
text: textContent,
attachments: normalizedAttachmentKeys,
skillTagId: selectedSkillTag?.id || null,
mode: activeComposerMode,
});
if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
toast.message("检测到重复发送,已自动忽略");
return;
}
setSendGuard(activeChatId, {
inFlight: true,
signature: sendSignature,
timestamp: Date.now(),
});
clearStreamImageGenerationStateForChat(activeChatId);
// 使用与 multimodal-input 相同的格式发送消息
try {
sendMessage({
role: "user",
parts: parts,
});
} catch (error) {
releaseSendGuard(activeChatId);
console.error("[CanvasChat] sendMessage failed:", error);
toast.error("发送失败,请重试");
return;
}
hasHydratedHistoryRef.current = true;
// 清空状态
setInput("");
setAttachments([]);
// 🆕 清空画布选中状态(防止 useEffect 重新添加标签)
clearSelection();
// 🆕 清空 contenteditable div 的内容
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = '';
console.log('[CanvasChat] 已清空输入框和选中状态');
}
}, [
activeChatId,
activeComposerMode,
agentRequiredUploadCount,
input,
attachments,
hasAgentUploadSlots,
selectedSkillTag,
selectedAgentPreset,
selectedModelId,
sendMessage,
clearSelection,
buildAutoContext,
buildContextPayload,
isVideoModel,
videoParams,
videoFrameSlots.length,
currentModel?.providerType,
selectedImageNodes,
isDetailGenerationMode,
aiDetailGenerationEnabled,
handleDetailGeneration,
ensureImagePointsBeforeSend,
getSendGuard,
clearStreamImageGenerationStateForChat,
releaseSendGuard,
setSendGuard,
shouldBlockDuplicateSend,
status,
]);
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
if (!firstImageUrl || hasSetCanvasTitle.current) {
return;
}
hasSetCanvasTitle.current = true;
console.log('[CanvasChat] 开始调用 auto-title API...');
try {
console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl);
const response = await fetch('/api/canvas/auto-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: firstImageUrl }),
});
console.log('[CanvasChat] auto-title 响应状态:', response.status);
if (response.ok) {
const data = await response.json();
const title = data.title || '未命名画布';
console.log('[CanvasChat] 识别到的标题:', title);
const rawId = chatId.startsWith('canvas-')
? chatId.replace('canvas-', '')
: chatId;
console.log('[CanvasChat] 更新画布:', rawId);
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
thumbnail: firstImageUrl,
}),
});
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
} else {
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
}
} catch (error) {
console.error('[CanvasChat] 自动命名失败:', error);
}
}, [chatId]);
const uploadImageFileAndAddToCanvas = useCallback(async (
file: File,
nodeIndex: number
): Promise<{ imageUrl: string; name: string; contentType: string; assetId?: string } | null> => {
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
return null;
}
if (file.size > 50 * 1024 * 1024) {
toast.error('图片大小不能超过 50MB');
return null;
}
const ext = file.type.split('/')[1] || 'png';
const fileName = (file.name || '').trim() || `粘贴图片-${Date.now()}-${nodeIndex + 1}.${ext}`;
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error('上传失败');
}
const data = await response.json();
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
const dimensions = await getImageDimensions(imageUrl);
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: 'image',
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
assetId,
status: 'completed',
title: fileName.replace(/\.[^.]+$/, ""),
prompt: fileName.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
return {
imageUrl,
name: fileName,
contentType: file.type || 'image/jpeg',
assetId,
};
} catch (_error) {
toast.error('图片上传失败');
return null;
}
}, [addNode]);
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
let firstImageUrl: string | null = null;
const baseNodeCount = nodes.length;
let uploadIndex = 0;
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of files) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
if (!uploaded) continue;
const attachment: PickedObjectAttachment = {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
};
updateAttachmentAtAgentSlot(targetSlotIndex, attachment);
currentSlots[targetSlotIndex] = attachment;
handledCount += 1;
uploadIndex += 1;
if (!firstImageUrl) {
firstImageUrl = uploaded.imageUrl;
}
}
if (handledCount > 0) {
toast.success(
handledCount === 1
? "已粘贴图片到智能体槽位"
: `已粘贴 ${handledCount} 张图片到智能体槽位`
);
}
if (handledCount < files.length) {
toast.info("智能体图片槽位已满,剩余图片未添加");
}
void maybeAutoSetCanvasTitle(firstImageUrl);
return;
}
if (isVideoModel && videoFrameSlots.length > 0) {
const slotCount = videoFrameSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of files) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
if (!uploaded) continue;
const attachment: PickedObjectAttachment = {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
};
updateAttachmentAtVideoSlot(targetSlotIndex, attachment);
currentSlots[targetSlotIndex] = attachment;
handledCount += 1;
uploadIndex += 1;
if (!firstImageUrl) {
firstImageUrl = uploaded.imageUrl;
}
}
if (handledCount > 0) {
toast.success(handledCount === 1 ? "已粘贴图片到视频槽位" : `已粘贴 ${handledCount} 张图片到视频槽位`);
}
if (handledCount < files.length) {
toast.info("视频模式图片槽位已满,剩余图片未添加");
}
void maybeAutoSetCanvasTitle(firstImageUrl);
return;
}
let handledCount = 0;
for (const file of files) {
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
if (!uploaded) continue;
handledCount += 1;
uploadIndex += 1;
if (!firstImageUrl) {
firstImageUrl = uploaded.imageUrl;
}
setAttachments((prev) => {
const newIndex = prev.length;
setTimeout(() => {
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [
...prev,
{
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
},
];
});
}
if (handledCount > 0) {
toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`);
}
void maybeAutoSetCanvasTitle(firstImageUrl);
}, [
agentUploadSlots.length,
attachments,
hasAgentUploadSlots,
insertImageTagInEditor,
isVideoModel,
maybeAutoSetCanvasTitle,
nodes.length,
updateAttachmentAtAgentSlot,
updateAttachmentAtVideoSlot,
uploadImageFileAndAddToCanvas,
videoFrameSlots.length,
]);
// 处理文件选择 - 上传后立即添加到画布
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
let firstImageUrl: string | null = null;
// 记录本次上传开始时的节点数量,用于计算新图片位置
const baseNodeCount = nodes.length;
let uploadIndex = 0;
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of Array.from(files)) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
if (!uploaded) continue;
updateAttachmentAtAgentSlot(targetSlotIndex, {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
});
currentSlots[targetSlotIndex] = {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
};
handledCount += 1;
uploadIndex += 1;
if (!firstImageUrl) {
firstImageUrl = uploaded.imageUrl;
}
}
if (handledCount > 0) {
toast.success(
handledCount === 1
? "图片已添加到智能体槽位"
: `已添加 ${handledCount} 张图片到智能体槽位`
);
}
if (handledCount < files.length) {
toast.info("智能体需要的图片槽位已满,剩余图片未添加");
}
void maybeAutoSetCanvasTitle(firstImageUrl);
e.target.value = '';
return;
}
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
continue;
}
if (file.size > 50 * 1024 * 1024) {
toast.error('图片大小不能超过 50MB');
continue;
}
// 上传文件
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error('上传失败');
}
const data = await response.json();
const imageUrl = data.url;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
// 记录第一张图片的 URL(用于自动命名和缩略图)
if (!firstImageUrl) {
firstImageUrl = imageUrl;
}
// 添加到附件列表并在编辑器中插入标签
setAttachments(prev => {
const newIndex = prev.length;
// 在编辑器中插入图片标签
setTimeout(() => {
insertImageTagInEditor(imageUrl, file.name, newIndex);
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
return [...prev, {
url: imageUrl,
name: file.name,
contentType: file.type,
assetId,
}];
});
// 获取图片原始尺寸后添加到画布
const dimensions = await getImageDimensions(imageUrl);
// 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠
const nodeIndex = baseNodeCount + uploadIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
uploadIndex++; // 递增索引
addNode({
type: 'image',
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
assetId,
status: 'completed',
title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
toast.success('图片已添加到画布');
} catch (error) {
toast.error('图片上传失败');
}
}
console.log('[CanvasChat] 检查是否需要自动命名:', {
firstImageUrl: !!firstImageUrl,
hasSetCanvasTitle: hasSetCanvasTitle.current
});
void maybeAutoSetCanvasTitle(firstImageUrl);
e.target.value = '';
};
// 移除附件(通过标签元素删除,支持同图多物品)
const removeAttachmentByTagElement = (tagEl: HTMLElement) => {
const url = tagEl.getAttribute('data-attachment-url') || '';
const isPicked = tagEl.getAttribute('data-picked-object') === 'true';
const objectName = tagEl.getAttribute('data-object-name') || '';
const objectPosition = tagEl.getAttribute('data-object-position') || '';
setAttachments(prev => prev.filter(a => {
if (!url) return true;
if (isPicked) {
return !(
a.url === url &&
a.pickedObject?.objectName === objectName &&
a.pickedObject?.objectPosition === objectPosition
);
}
return a.url !== url;
}));
if (url) {
console.log('[CanvasChat] 已移除附件:', url.substring(0, 50));
}
};
// 从编辑器 DOM 同步附件列表,避免删除标签后仍发送
const syncAttachmentsWithEditor = useCallback((editableDiv?: HTMLDivElement | null) => {
const container = editableDiv || (textareaRef.current as HTMLDivElement | null);
if (!container) return;
const tagElements = Array.from(container.querySelectorAll('[data-attachment-url]')) as HTMLElement[];
const tagKeys = new Set<string>();
tagElements.forEach((el) => {
const url = el.getAttribute('data-attachment-url') || '';
if (!url) return;
const isPicked = el.getAttribute('data-picked-object') === 'true';
if (isPicked) {
const objectName = el.getAttribute('data-object-name') || '';
const objectPosition = el.getAttribute('data-object-position') || '';
tagKeys.add(`picked|${url}|${objectName}|${objectPosition}`);
} else {
tagKeys.add(`image|${url}`);
}
});
setAttachments(prev => prev.filter((att) => {
if (att.pickedObject) {
const key = `picked|${att.url}|${att.pickedObject.objectName}|${att.pickedObject.objectPosition}`;
return tagKeys.has(key);
}
return tagKeys.has(`image|${att.url}`);
}));
}, []);
useEffect(() => {
if (isDetailGenerationMode && !detailModeSwitchRef.current) {
setDetailModeMessageStartIndex(messages.length);
}
detailModeSwitchRef.current = isDetailGenerationMode;
}, [isDetailGenerationMode, messages.length]);
const visibleMessages = useMemo(
() => (isDetailGenerationMode ? messages.slice(detailModeMessageStartIndex) : messages),
[detailModeMessageStartIndex, isDetailGenerationMode, messages]
);
const isLoading = status === 'streaming' || status === 'submitted';
const isSubmitting = isLoading || isDetailGenerating;
const hasInputOrAttachments = Boolean(
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
);
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
const composerPlaceholder = isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: activeComposerMode === "agent" && selectedAgentPreset?.type === "text"
? "可上传图片做分析,也可直接输入文案需求"
: hasAgentUploadSlots
? "请先上传产品图1、产品图2、模特图,再补充说明"
: selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求`
: canvasDynamicPlaceholder || "请输入你的设计需求,例如:绿色椅子 替换 Image");
const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false;
const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
const detailProgressPercent =
detailProgress.total > 0
? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100))
: 0;
const canSend = isDetailGenerationMode
? detailValidationIssues.length === 0 && !isDetailGenerating
: hasInputOrAttachments && hasEnoughAgentUploads;
const currentVideoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
: null;
const showVideoAttachmentButton = Boolean(
isVideoModel &&
currentVideoImageLimit &&
currentVideoImageLimit.max > 0 &&
videoFrameSlots.length === 0
);
const lastUserIndexForMarkers = visibleMessages.findLastIndex(m => m.role === "user");
const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? visibleMessages.slice(lastUserIndexForMarkers + 1) : visibleMessages;
const hasGenerationStart = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_START -->")
)
);
const hasGenerationEnd = messagesAfterLastUser.some(msg =>
msg.role === "assistant" && msg.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
)
);
const shouldShowImageLoading =
((hasGenerationStart && !hasGenerationEnd) || Boolean(streamImageGenerationState)) &&
!!currentModel;
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
const loadingProviderType =
streamImageGenerationState?.providerType ||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType);
const loadingModelName =
streamImageGenerationState?.modelName ||
(activeComposerMode === "agent"
? "智能处理"
: currentModel?.displayName || undefined);
const loadingPhase =
streamImageGenerationState?.phase === "queued" ||
streamImageGenerationState?.phase === "processing" ||
streamImageGenerationState?.phase === "finalizing" ||
streamImageGenerationState?.phase === "failed"
? streamImageGenerationState.phase
: "processing";
const renderableVisibleMessages = useMemo(() => {
return visibleMessages.reduce<ChatMessage[]>((acc, message) => {
const rawParts = (message.parts || []) as any[];
const cleanedParts = rawParts.map((part: any) => {
if (part.type === "text" && part.text) {
return {
...part,
text: String(part.text)
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.trim(),
};
}
return part;
});
const fallbackContent =
typeof (message as any).content === "string"
? String((message as any).content).trim()
: "";
if (cleanedParts.length === 0 && fallbackContent) {
cleanedParts.push({ type: "text", text: fallbackContent } as any);
}
let hasContent = false;
for (const part of cleanedParts) {
if (part?.type === "text" && part?.text && String(part.text).trim().length > 0) {
hasContent = true;
break;
}
const fileUrl =
typeof part?.url === "string" && part.url.trim()
? part.url
: typeof part?.data === "string" && part.data.trim()
? part.data
: "";
if ((part?.type === "file" || part?.type === "image") && fileUrl) {
hasContent = true;
break;
}
}
const attachmentsList = (message as any).attachments || [];
if (!hasContent && attachmentsList.length > 0) {
hasContent = true;
}
if (!hasContent) {
return acc;
}
acc.push({ ...message, parts: cleanedParts } as ChatMessage);
return acc;
}, []);
}, [visibleMessages]);
const isDetailEmptyState =
isDetailGenerationMode &&
renderableVisibleMessages.length === 0 &&
!isLoading &&
!shouldShowImageLoading;
const renderModelSelectContent = () => (
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6]/90 p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{selectableModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="h-[34px] rounded-xl px-2.5 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-5 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
{model.avatarUrl ? (
<img
src={buildPreviewImageUrl(model.avatarUrl, 40)}
alt={model.displayName}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== model.avatarUrl) {
event.currentTarget.src = model.avatarUrl || "";
}
}}
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[9px] font-medium text-[#6b7280]">
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
</span>
)}
</span>
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
</span>
{modelSelectValue === model.id ? (
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
) : null}
</span>
</SelectItem>
))}
{selectableModels.length === 0 ? (
<div className="px-2 py-2 text-xs text-[#6b7280]">暂无可用模型</div>
) : null}
</div>
</SelectContent>
);
const createNewChatSession = useCallback(() => {
const now = Date.now();
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
const fallbackTitle = `对话 ${chatSessions.length + 1}`;
const newSession: CanvasChatSession = {
id: newChatSessionId,
title: fallbackTitle,
createdAt: now,
updatedAt: now,
};
releaseSendGuard(activeChatId);
setChatSessions((prevSessions) => [newSession, ...prevSessions]);
setActiveChatId(newChatSessionId);
toast.success("已新建聊天窗口");
}, [activeChatId, chatSessions.length, releaseSendGuard]);
const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) {
setIsSessionMenuOpen(false);
return;
}
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
releaseSendGuard(activeChatId);
setActiveChatId(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId, releaseSendGuard]);
const activeSessionTitle = useMemo(() => {
const current = chatSessions.find((session) => session.id === activeChatId);
return String(current?.title || "").trim();
}, [chatSessions, activeChatId]);
const displayCanvasName = useMemo(() => {
if (activeSessionTitle) return activeSessionTitle;
const fallbackTitle = String(projectTitle || "").trim();
return fallbackTitle || "未命名画布";
}, [activeSessionTitle, projectTitle]);
return (
<div className="relative flex h-full min-h-0 flex-col bg-white dark:bg-zinc-950">
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */}
{detailSlotPickerIndex !== null && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 animate-in fade-in duration-200">
<div className="flex flex-col items-center gap-4 p-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-primary/5 animate-pulse">
<ImagePlus className="h-6 w-6" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">正在从画布选择图片</h3>
<p className="text-sm text-muted-foreground">
请点击画布中的任意图片以填入
<span className="font-medium text-primary">
“{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}”
</span>
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setDetailSlotPickerIndex(null)}
className="mt-2"
>
取消选择
</Button>
</div>
</div>
)}
{/* Header - Minimal with Icons */}
<div className="z-20 flex items-center justify-between gap-3 p-4 pb-2">
<div className="min-w-0 flex flex-1 items-center gap-2 pr-2">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-md bg-[#eef2f7] text-[#6b7280]">
<MessageCircle className="h-3.5 w-3.5" />
</span>
<h2
className="min-w-0 flex-1 truncate text-[14px] font-semibold leading-tight tracking-tight text-[#111827]"
title={displayCanvasName}
>
{displayCanvasName}
</h2>
</div>
<div className="flex shrink-0 items-center">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted"
onClick={createNewChatSession}
title="新建聊天窗口"
>
<Plus className="h-4 w-4" />
</Button>
<div className="relative" ref={sessionMenuRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full hover:bg-muted",
isSessionMenuOpen && "bg-muted"
)}
onClick={() => setIsSessionMenuOpen((open) => !open)}
title="聊天窗口记录"
>
<History className="h-4 w-4" />
</Button>
{isSessionMenuOpen && (
<div className="absolute right-0 top-10 w-64 rounded-xl border bg-white dark:bg-zinc-950 shadow-xl p-2">
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
<div className="max-h-56 overflow-y-auto space-y-1">
{chatSessions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
) : (
chatSessions.map((session, index) => {
const isActive = session.id === activeChatId;
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<button
key={session.id}
type="button"
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
onClick={() => switchToSession(session.id)}
>
<div className="text-sm font-medium truncate">
{session.title || `对话 ${index + 1}`}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
最近更新 {timeLabel}
</div>
</button>
);
})
)}
</div>
</div>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('[CanvasChatPanel] File list button clicked');
if (onOpenFileList) {
onOpenFileList();
}
}}
title="已生成文件列表"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-muted"
onClick={onClose}
disabled={!onClose}
title="收起聊天面板"
>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 消息列表 */}
{isDetailEmptyState ? (
<div className="flex-1 min-h-0" />
) : (
<div
key={`${activeChatId}-${messageViewportKey}`}
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4",
isDetailGenerationMode && renderableVisibleMessages.length === 0
? "pt-10"
: "pt-4"
)}
>
<div className="space-y-6 overflow-x-hidden pb-24 pr-1">
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 && (
<div className={cn("flex flex-col h-full", isDetailGenerationMode ? "pt-0" : "pt-4")}>
{/* Greeting */}
<div className={cn(isDetailGenerationMode ? "mb-4" : "mb-8")}>
<h1 className="mb-2 text-2xl font-bold">
<span>Hi</span>
{greetingUserName ? (
<>
<span>, </span>
<span className="text-blue-600">{greetingUserName}</span>
<span> {greetingPeriodText}</span>
</>
) : (
<span> {greetingPeriodText}</span>
)}
</h1>
<p className="text-muted-foreground">
开始创作吧!
{Number.isFinite(totalGeneratedImages) ? (
<>
{" "}累计为你生成了{" "}
<span className="text-blue-600 font-semibold">
{totalGeneratedImages.toLocaleString()}
</span>{" "}
张图。
</>
) : null}
</p>
</div>
{canvasSkillTags.length > 0 && (
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-4">
<div className="mb-3 text-center">
<h3 className="text-[16px] font-semibold text-[#1f2937]">{canvasSkillTagsTitle}</h3>
</div>
<div className="grid grid-cols-3 gap-2">
{canvasSkillTags.map((tag) => {
const isActive = selectedSkillTag?.id === tag.id;
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
return (
<button
key={tag.id}
type="button"
onClick={() => {
setSelectedSkillTagId(tag.id);
const editor = textareaRef.current as HTMLDivElement | null;
editor?.focus();
}}
className={cn(
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-2xl border px-2 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
)}
>
{tag.iconUrl ? (
<img
src={buildPreviewImageUrl(tag.iconUrl, 48)}
alt={tag.name}
className="h-3.5 w-3.5 rounded-sm object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== tag.iconUrl) {
event.currentTarget.src = tag.iconUrl || "";
}
}}
/>
) : (
<TagIcon className="h-3.5 w-3.5 text-current opacity-70" />
)}
<span className="truncate">{tag.name}</span>
</button>
);
})}
</div>
</div>
)}
{/* Refresh - Removed */}
</div>
)}
{renderableVisibleMessages.map((message, index) => {
const assistantTextParts = message.role === "assistant"
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
: [];
const nearestUserImageKeys = getNearestUserImageUrlKeys(
renderableVisibleMessages,
index
);
const assistantModelName =
assistantTextParts
.map((part: any) => extractCanvasModelName(part.text || ""))
.find((name) => Boolean(name)) || "";
const hasAssistantImages = assistantTextParts.some((part: any) =>
extractAssistantImageUrls(part.text || "", nearestUserImageKeys).length > 0
);
const hasAssistantVideos = assistantTextParts.some((part: any) =>
extractVideoUrls(part.text || "").length > 0
);
const hasAssistantMedia = hasAssistantImages || hasAssistantVideos;
const isAgentAutoMode = selectedModelId === "auto";
const displayAssistantModelName =
isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName;
const hasAssistantText = assistantTextParts.some((part: any) =>
stripCanvasModelRoutingLines(stripMediaMarkup(part.text || "")).length > 0
);
const isImageOnlyAssistant =
message.role === "assistant" &&
hasAssistantMedia &&
!hasAssistantText;
const nextMessage = renderableVisibleMessages[index + 1] as ChatMessage | undefined;
const nextMessageUserImageKeys = getNearestUserImageUrlKeys(
renderableVisibleMessages,
index + 1
);
const nextAssistantHasMedia = messageHasAssistantMedia(
nextMessage,
nextMessageUserImageKeys
);
const isLatestAssistantDuringGeneration =
message.role === "assistant" &&
hasGenerationStart &&
!hasGenerationEnd &&
index === renderableVisibleMessages.length - 1;
const hideVoteActions =
message.role === "assistant" &&
((!hasAssistantMedia && nextAssistantHasMedia) || isLatestAssistantDuringGeneration);
const showVoteActions = message.role === "assistant" && !hideVoteActions;
const showModelMeta = hideVoteActions && Boolean(displayAssistantModelName);
const mediaModelName =
displayAssistantModelName ||
(message.role === "assistant" && !isAgentAutoMode
? currentModel?.displayName || ""
: "");
const shouldTypeAssistantMessage =
message.role === "assistant" &&
isLoading &&
!hasAssistantMedia &&
index === renderableVisibleMessages.length - 1;
const messageTimestamp = getMessageTimestamp(message);
const prevMessageTimestamp =
index > 0 ? getMessageTimestamp(renderableVisibleMessages[index - 1] as ChatMessage) : null;
const showDateSeparator = Boolean(
messageTimestamp &&
(!prevMessageTimestamp ||
getMessageDayKey(prevMessageTimestamp) !== getMessageDayKey(messageTimestamp))
);
const currentVote = messageVotes?.find(
(vote) => vote.messageId === message.id
);
const isVotingCurrentMessage = votingMessageId === message.id;
return (
<div key={message.id} className="space-y-3">
{showDateSeparator && messageTimestamp ? (
<div className="text-[14px] font-medium text-[#a6adba]">
{formatMessageDateLabel(messageTimestamp)}
</div>
) : null}
<div
className={cn(
"flex w-full min-w-0",
message.role === 'user' ? "justify-end" : "justify-start"
)}
>
{/* 消息区域 */}
<div className={cn(
"flex min-w-0 flex-col gap-2",
message.role === 'user'
? "max-w-[88%] items-end"
: "w-full max-w-[88%] items-start"
)}>
{message.role === 'user' ? (
<div className="rounded-2xl bg-[#f1f2f4] px-3 py-2">
<div className="flex flex-wrap items-center gap-1.5">
{(() => {
const renderedUserParts: React.ReactNode[] = [];
message.parts?.forEach((part: any, idx: number) => {
const fileUrl =
typeof part?.url === "string" && part.url.trim()
? part.url
: typeof part?.data === "string" && part.data.trim()
? part.data
: "";
if (part.type === 'file' && fileUrl) {
renderedUserParts.push(
<span
key={`part-${idx}`}
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
>
<img
src={buildPreviewImageUrl(fileUrl, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== fileUrl) {
event.currentTarget.src = fileUrl;
}
}}
/>
<span className="max-w-[80px] truncate text-foreground">{part.name || 'Image'}</span>
</span>
);
return;
}
if (part.type === 'text' && part.text) {
const text = String(part.text)
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
.trim();
if (!text) return;
renderedUserParts.push(
<span key={`part-${idx}`} className="text-sm text-foreground">{text}</span>
);
}
});
if (
!message.parts?.some((p: any) => p.type === 'file') &&
(message as any).attachments?.length > 0
) {
(message as any).attachments.forEach((att: any, idx: number) => {
if (!att?.url) return;
renderedUserParts.push(
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
>
<img
src={buildPreviewImageUrl(att.url, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== att.url) {
event.currentTarget.src = att.url;
}
}}
/>
<span className="max-w-[80px] truncate text-foreground">{att.name || 'Image'}</span>
</span>
);
});
}
if (renderedUserParts.length === 0) {
const fallbackText =
extractUserTextPrompt(message as any) ||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
renderedUserParts.push(
<span key="fallback-text" className="text-sm text-foreground">
{fallbackText}
</span>
);
}
return renderedUserParts;
})()}
</div>
</div>
) : (
<>
<div
className={cn(
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
isImageOnlyAssistant
? "bg-transparent px-0 py-0"
: "bg-transparent px-0 py-0"
)}
>
{/* 消息内容 */}
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
{message.parts?.map((part: any, i: number) => {
if (part.type === 'text') {
// 提取并显示图片/视频
const rawText = part.text || '';
const imageUrls = extractAssistantImageUrls(
rawText,
nearestUserImageKeys
);
const videoUrls = extractVideoUrls(rawText);
const textWithoutImages = stripCanvasModelRoutingLines(
stripMediaMarkup(rawText)
);
const analysisSection = extractCanvasImageAnalysisSection(textWithoutImages);
const plainText = normalizeCanvasAgentText(textWithoutImages);
const analysisToggleKey = `${message.id}-${i}-analysis`;
const analysisOpen = analysisCollapseState[analysisToggleKey] ?? true;
const analysisTypingKey = `${message.id}-${i}-analysis-typing`;
const plainTextTypingKey = `${message.id}-${i}-plain-typing`;
const shouldAnimateAnalysis =
shouldTypeAssistantMessage &&
Boolean(analysisSection?.analysis) &&
!typedAssistantSections[analysisTypingKey];
const shouldAnimatePlainText =
shouldTypeAssistantMessage &&
!analysisSection &&
Boolean(plainText) &&
!typedAssistantSections[plainTextTypingKey];
const shouldShowDoneSummary =
!isDetailGenerationMode &&
imageUrls.length > 0 &&
!plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) &&
!isLatestAssistantDuringGeneration;
const renderPlainText = (text: string, key: string) => (
<div
key={key}
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
>
{text}
</div>
);
return (
<div key={i}>
{analysisSection ? (
<div className="space-y-3">
{analysisSection.before
? renderPlainText(analysisSection.before, `before-${i}`)
: null}
<Collapsible
className="my-2 rounded-[20px] bg-[#f4f5f7] px-5 py-4"
open={analysisOpen}
onOpenChange={(open) =>
setAnalysisCollapseState((prev) => ({
...prev,
[analysisToggleKey]: 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 w-6 items-center justify-center rounded-full bg-white text-[#9aa1ad] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.42)]">
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
aria-hidden="true"
>
<path
d="M10.5 3.75a6.75 6.75 0 1 0 4.136 12.085l4.047 4.046a.75.75 0 1 0 1.06-1.06l-4.046-4.047A6.75 6.75 0 0 0 10.5 3.75Zm-3.75 6.75a3.75 3.75 0 1 1 7.5 0 3.75 3.75 0 0 1-7.5 0Z"
/>
</svg>
</span>
<span className="text-base font-semibold tracking-tight text-[#9aa1ad]">
图片分析
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-[#9aa1ad] transition-transform duration-200",
analysisOpen ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"pt-3 text-[15px] font-medium leading-7 text-[#2f3640]",
"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"
)}
>
<CanvasTypewriterText
text={analysisSection.analysis}
typingKey={analysisTypingKey}
enabled={shouldAnimateAnalysis}
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
onActiveChange={handleTypewriterActiveChange}
onComplete={handleTypewriterComplete}
/>
</CollapsibleContent>
</Collapsible>
{analysisSection.after
? renderPlainText(analysisSection.after, `after-${i}`)
: null}
</div>
) : plainText ? (
<CanvasTypewriterText
text={plainText}
typingKey={plainTextTypingKey}
enabled={shouldAnimatePlainText}
className="break-words [overflow-wrap:anywhere]"
onActiveChange={handleTypewriterActiveChange}
onComplete={handleTypewriterComplete}
/>
) : null}
{imageUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages ? "mt-3" : "mt-0")}>
{mediaModelName ? (
<div className="flex items-center gap-1.5 text-[#a4acb8]">
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path d="M10.8 2.4a2.4 2.4 0 0 1 2.4 0l6.2 3.58a2.4 2.4 0 0 1 1.2 2.08v7.16a2.4 2.4 0 0 1-1.2 2.08l-6.2 3.58a2.4 2.4 0 0 1-2.4 0l-6.2-3.58a2.4 2.4 0 0 1-1.2-2.08V8.06a2.4 2.4 0 0 1 1.2-2.08l6.2-3.58Zm1.2 5.1a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Zm0 2.25a2.25 2.25 0 1 1 0 4.5 2.25 2.25 0 0 1 0-4.5Z" />
</svg>
<span className="text-[12px] font-medium">{mediaModelName}</span>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{imageUrls.map((url, idx) => (
<div
key={idx}
className={cn(
"relative group overflow-hidden border transition-all",
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
)}
>
<img
src={buildPreviewImageUrl(url, 960)}
alt=""
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
style={{ maxHeight: "260px" }}
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== url) {
event.currentTarget.src = url;
}
}}
onClick={() => setFullscreenPreview({ url })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
}}
/>
<button
type="button"
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDownloadOriginalImage(url);
}}
title="下载原图"
>
<Download className="h-3.5 w-3.5" />
原图
</button>
</div>
))}
</div>
{shouldShowDoneSummary ? (
<p className="text-[14px] font-medium leading-6 text-[#2f3640]">
{CANVAS_IMAGE_DONE_SUMMARY}
</p>
) : null}
</div>
)}
{videoUrls.length > 0 && (
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
{videoUrls.map((url, idx) => (
<div
key={`video-${idx}`}
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
>
<video
src={url}
controls
className="block w-full rounded-xl bg-black"
style={{ maxHeight: "320px" }}
/>
<a
href={url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
>
下载视频
</a>
</div>
))}
</div>
)}
</div>
);
}
return null;
})}
</div>
</div>
<div className="mt-1 flex items-center gap-2 text-[#8f96a3]">
{showModelMeta ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium tracking-tight text-[#a2a9b5]">
<Eye className="h-3.5 w-3.5 opacity-80" />
<span>{displayAssistantModelName}</span>
</span>
) : null}
{showVoteActions ? (
<>
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
currentVote?.isUpvoted && "text-[#22a06b] bg-emerald-50/80"
)}
title="点赞"
disabled={isVotingCurrentMessage}
onClick={() => void handleAssistantVote(message.id, "up")}
>
<ThumbsUp className="h-4 w-4" />
</button>
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
currentVote && !currentVote.isUpvoted && "text-[#d94848] bg-rose-50/80"
)}
title="点踩"
disabled={isVotingCurrentMessage}
onClick={() => void handleAssistantVote(message.id, "down")}
>
<ThumbsDown className="h-4 w-4" />
</button>
</>
) : null}
</div>
</>
)}
</div>
</div>
</div>
);
})}
{shouldRenderImageLoading && (
<ImageGenerationLoading
imageCount={loadingImageCount}
maxImages={6}
providerType={loadingProviderType}
modelName={loadingModelName}
phase={loadingPhase}
/>
)}
{isLoading && !shouldRenderImageLoading && (
<div className="flex w-full justify-start">
<div className="flex items-center gap-2 text-[#8e97a5]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[16px]">分析中</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
)}
{fullscreenPreview && createPortal(
<div
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
onClick={() => setFullscreenPreview(null)}
>
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览"
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/20 transition-colors"
onClick={(event) => {
event.stopPropagation();
void handleDownloadOriginalImage(fullscreenPreview.url);
}}
aria-label="下载原图"
title="下载原图"
>
<Download className="h-4 w-4" />
下载原图
</button>
<img
src={fullscreenPreview.url}
alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
点击空白处或按 Esc 关闭
</div>
</div>,
document.body
)}
{shouldShowCanvasBanner ? (
<div className="px-4 pb-2">
<div
className={cn(
"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
)}
onClick={() => {
if (siteSettings?.bannerLink) {
window.open(siteSettings.bannerLink, "_blank");
}
}}
>
<div className="flex items-center gap-2">
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
<Box className="h-4 w-4" />
</div>
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
</div>
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
</div>
</div>
) : null}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-white dark:bg-zinc-950 border-t">
<div
className="relative flex flex-col bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background"
data-canvas-chat-input-anchor="true"
>
{isDetailGenerationMode && (
<div
className={cn(
"order-3 mx-3 mb-11 overflow-y-auto rounded-2xl border border-black/10 bg-white p-3 pr-2",
"mt-1",
renderableVisibleMessages.length === 0 ? "max-h-[62vh]" : "max-h-[54vh]"
)}
>
<div className="flex items-center justify-between gap-3 border-b border-border/5 pb-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/10 to-fuchsia-500/10 ring-1 ring-inset ring-black/5 dark:ring-white/10">
<Sparkles className="h-4.5 w-4.5 text-violet-600 dark:text-violet-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
AI详情生成工作台
</h3>
<span className="rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-medium text-black/60 dark:bg-white/10 dark:text-white/70">
PC端
</span>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
全链路智能生成 · {DETAIL_TARGET_WIDTH}x{DETAIL_TARGET_HEIGHT}px
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors",
detailProgress.status === "running"
? "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400"
: detailProgress.status === "done"
? "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-400"
: detailProgress.status === "error"
? "bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20 dark:bg-red-500/10 dark:text-red-400"
: "bg-zinc-100 text-zinc-600 ring-1 ring-inset ring-zinc-500/10 dark:bg-zinc-800 dark:text-zinc-400"
)}
>
<div className={cn(
"h-1.5 w-1.5 rounded-full",
detailProgress.status === "running" ? "animate-pulse bg-blue-600" :
detailProgress.status === "done" ? "bg-emerald-600" :
detailProgress.status === "error" ? "bg-red-600" : "bg-zinc-400"
)} />
{detailProgress.status === "running"
? "生成中"
: detailProgress.status === "done"
? "已完成"
: detailProgress.status === "error"
? "异常"
: "待启动"}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={() => setIsDetailWorkbenchCollapsed((prev) => !prev)}
>
{isDetailWorkbenchCollapsed ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
{isDetailWorkbenchCollapsed ? (
<button
type="button"
className="mt-4 flex w-full items-center justify-between rounded-lg border border-dashed border-border/50 bg-zinc-50/50 px-4 py-2.5 text-xs text-muted-foreground transition-colors hover:bg-zinc-100 dark:bg-zinc-900/50 dark:hover:bg-zinc-800"
onClick={() => setIsDetailWorkbenchCollapsed(false)}
>
<span>配置面板已收起</span>
<span className="text-[10px] font-medium text-primary">点击展开</span>
</button>
) : (
<>
<div className="mt-4 rounded-xl border border-border/50 bg-zinc-50/50 p-3 dark:bg-zinc-900/50">
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium text-foreground">
{detailProgress.stage || "准备就绪"}
</span>
<span className="text-[10px] text-muted-foreground">
{detailProgress.current}/{detailProgress.total || 0} 步骤
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out",
detailProgress.status === "error"
? "bg-red-500"
: detailProgress.status === "done"
? "bg-emerald-500"
: "bg-primary"
)}
style={{ width: `${detailProgressPercent}%` }}
/>
</div>
{!detailProgress.stage && (
<p className="mt-2 text-[10px] text-muted-foreground">
请先补齐必填信息与素材,然后点击下方发送按钮开始生成。
</p>
)}
</div>
<div className="mt-3 rounded-xl border border-border/50 bg-zinc-50/40 p-3 dark:bg-zinc-900/40">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium text-foreground">生成路线</p>
<span className="text-[10px] text-muted-foreground">
{detailGenerationMode === "classic" ? "当前:传统生图" : "当前:AGENT排版"}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{DETAIL_GENERATION_MODE_OPTIONS.map((option) => {
const selected = detailGenerationMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => {
if (isDetailGenerating) return;
setDetailGenerationMode(option.value);
}}
disabled={isDetailGenerating}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-all duration-150",
isDetailGenerating && "cursor-not-allowed opacity-60",
selected
? "border-[#5b7cff] bg-[#eef3ff] text-[#2f57d8] shadow-[0_0_0_1px_rgba(91,124,255,0.28)]"
: "border-border/60 bg-background text-muted-foreground hover:border-[#9fb3ff] hover:bg-[#f8faff] hover:text-foreground"
)}
aria-pressed={selected}
>
<div className="flex items-center justify-between">
<div className="text-[11px] font-medium">{option.label}</div>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</div>
<div className="mt-1 text-[10px] leading-snug opacity-80">
{option.description}
</div>
</button>
);
})}
</div>
</div>
<div className="mt-4 space-y-3">
{/* Required Info */}
<Collapsible
open={detailSections.required}
onOpenChange={() => toggleDetailSection("required")}
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400">
<AlertCircle className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-xs font-semibold text-foreground">核心参数</p>
<p className="text-[10px] text-muted-foreground">人群 / 场景 / 需求 (必填)</p>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailSections.required && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-3 border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30 xl:grid-cols-4">
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">商品SPU</label>
<input
value={detailBrief.productSpu}
onChange={(event) => updateDetailBriefField("productSpu", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.productSpu}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="建议填写"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">需求类型</label>
<input
value={detailBrief.demandType}
onChange={(event) => updateDetailBriefField("demandType", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.demandType}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="建议填写"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">主购买人群 <span className="text-red-500">*</span></label>
<input
value={detailBrief.targetAudience}
onChange={(event) => updateDetailBriefField("targetAudience", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.targetAudience}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="必填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">需求场景 <span className="text-red-500">*</span></label>
<input
value={detailBrief.usageScenario}
onChange={(event) => updateDetailBriefField("usageScenario", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.usageScenario}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="必填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">核心需求 <span className="text-red-500">*</span></label>
<input
value={detailBrief.coreNeed}
onChange={(event) => updateDetailBriefField("coreNeed", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.coreNeed}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="必填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">产品名称</label>
<input
value={detailBrief.productName}
onChange={(event) => updateDetailBriefField("productName", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.productName}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">主使用者</label>
<input
value={detailBrief.mainUser}
onChange={(event) => updateDetailBriefField("mainUser", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.mainUser}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">触发时刻</label>
<input
value={detailBrief.triggerMoment}
onChange={(event) => updateDetailBriefField("triggerMoment", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.triggerMoment}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">用户顾虑</label>
<input
value={detailBrief.riskConcern}
onChange={(event) => updateDetailBriefField("riskConcern", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.riskConcern}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">期望结果</label>
<input
value={detailBrief.expectedResult}
onChange={(event) => updateDetailBriefField("expectedResult", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.expectedResult}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">核心卖点关键词</label>
<input
value={detailBrief.coreSellingPoint}
onChange={(event) => updateDetailBriefField("coreSellingPoint", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.coreSellingPoint}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">一句话定位</label>
<input
value={detailBrief.positioningStatement}
onChange={(event) => updateDetailBriefField("positioningStatement", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.positioningStatement}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Strategy Info */}
<Collapsible
open={detailSections.strategy}
onOpenChange={() => toggleDetailSection("strategy")}
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-purple-50 text-purple-600 dark:bg-purple-500/10 dark:text-purple-400">
<Brain className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-xs font-semibold text-foreground">策略信息</p>
<p className="text-[10px] text-muted-foreground">品牌语气 / 竞品 / 禁用词</p>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailSections.strategy && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-3 border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30 xl:grid-cols-4">
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">价格带</label>
<input
value={detailBrief.priceBand}
onChange={(event) => updateDetailBriefField("priceBand", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.priceBand}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">品牌语气</label>
<input
value={detailBrief.brandTone}
onChange={(event) => updateDetailBriefField("brandTone", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.brandTone}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">竞品对标</label>
<input
value={detailBrief.competitorReference}
onChange={(event) => updateDetailBriefField("competitorReference", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.competitorReference}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="选填"
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">禁用表达</label>
<input
value={detailBrief.bannedWords}
onChange={(event) => updateDetailBriefField("bannedWords", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.bannedWords}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="逗号分隔"
/>
</div>
<div className="col-span-2 xl:col-span-4 space-y-1.5">
<label className="text-[10px] font-medium text-muted-foreground">用户原话样本</label>
<textarea
value={detailBrief.userVoice}
onChange={(event) => updateDetailBriefField("userVoice", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.userVoice}
className="min-h-[64px] w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
placeholder="建议3-5条"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Assets Info */}
<Collapsible
open={detailSections.assets}
onOpenChange={() => toggleDetailSection("assets")}
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400">
<ImagePlus className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-xs font-semibold text-foreground">素材中心</p>
<p className="text-[10px] text-muted-foreground">5张产品 + 1张模特 + 3张搭配</p>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailSections.assets && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30">
<div className="mb-3 flex items-start gap-2 text-[10px] text-muted-foreground">
<AlertCircle className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
<span>Agent 会自动判断是否使用模特图;若上传搭配图,将自动新增一屏“场景化产品搭配展示”用于关联销售。</span>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{DETAIL_SLOT_CONFIGS.map((config, index) => {
const slotAttachment = detailSlots[index];
const isUploading = detailSlotUploading === index;
const isPickingFromCanvas = detailSlotPickerIndex === index;
return (
<div
key={config.key}
className={cn(
"group relative aspect-square overflow-hidden rounded-lg border transition-all",
slotAttachment
? "border-border bg-background shadow-sm"
: "border-dashed border-border/60 bg-background/50 hover:border-primary/50 hover:bg-background",
isPickingFromCanvas && "ring-2 ring-primary ring-offset-2"
)}
>
<input
type="file"
accept="image/*"
className="hidden"
ref={(element) => {
detailSlotInputRefs.current[index] = element;
}}
onChange={(event) => {
void handleDetailSlotFileChange(index, event);
}}
/>
{slotAttachment ? (
<>
<button
type="button"
className="h-full w-full cursor-pointer"
onClick={() => detailSlotInputRefs.current[index]?.click()}
>
<img
src={buildPreviewImageUrl(slotAttachment.url, 520)}
alt={config.label}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
}
}}
/>
</button>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-1 py-1 text-center text-[10px] text-white backdrop-blur-sm">
{config.label}
</div>
<div className="absolute left-1 top-1 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<button
type="button"
className={cn(
"rounded px-1.5 py-0.5 text-[10px] text-white shadow-sm backdrop-blur-md",
isPickingFromCanvas ? "bg-primary" : "bg-black/50 hover:bg-black/70"
)}
onClick={() => activateDetailSlotCanvasPicker(index)}
>
画布
</button>
<button
type="button"
className="rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-white shadow-sm backdrop-blur-md hover:bg-black/70"
onClick={() => detailSlotInputRefs.current[index]?.click()}
>
更换
</button>
</div>
<button
type="button"
className="absolute right-1 top-1 rounded bg-red-500/80 px-1.5 py-0.5 text-[10px] text-white shadow-sm backdrop-blur-md opacity-0 transition-opacity duration-200 hover:bg-red-600 group-hover:opacity-100"
onClick={() => updateDetailSlotAttachment(index, null)}
>
移除
</button>
</>
) : (
<>
<button
type="button"
className={cn(
"absolute right-1 top-1 rounded px-1.5 py-0.5 text-[10px] text-white shadow-sm transition-all",
isPickingFromCanvas ? "bg-primary opacity-100" : "bg-zinc-500/50 opacity-0 hover:bg-zinc-500 group-hover:opacity-100"
)}
onClick={() => activateDetailSlotCanvasPicker(index)}
disabled={isSubmitting || isUploading}
>
画布
</button>
<button
type="button"
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => detailSlotInputRefs.current[index]?.click()}
disabled={isSubmitting || isUploading}
>
{isUploading ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted/50 transition-colors group-hover:bg-muted">
<Plus className="h-4 w-4" />
</div>
)}
<span className="text-[10px] font-medium">{config.label}</span>
</button>
</>
)}
</div>
);
})}
</div>
<div className="mt-3 flex items-center justify-between text-[10px] text-muted-foreground">
<div>
当前素材:产品图 <span className={detailProductCount === 5 ? "text-emerald-500 font-medium" : ""}>{detailProductCount}/5</span>,
模特图 <span className={detailModelReady ? "text-emerald-500 font-medium" : ""}>{detailModelReady ? "1/1" : "0/1"}</span>,
搭配图 {detailBundleCount}/3
</div>
{detailSlotPickerIndex !== null && (
<div className="animate-pulse font-medium text-primary">
请在画布中选择图片...
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Archives Info */}
<Collapsible
open={detailSections.archives}
onOpenChange={() => toggleDetailSection("archives")}
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
<History className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-xs font-semibold text-foreground">项目存档</p>
<p className="text-[10px] text-muted-foreground">历史记录与回溯</p>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailSections.archives && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30">
{detailArchives.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border/60 px-3 py-6 text-center">
<p className="text-[11px] text-muted-foreground">暂无存档记录</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">生成成功后将自动保存</p>
</div>
) : (
<div className="max-h-48 space-y-2 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-zinc-200 dark:scrollbar-thumb-zinc-800">
{detailArchives.map((archive) => (
<div
key={archive.id}
className="group flex items-center justify-between gap-3 rounded-lg border border-border/40 bg-background px-3 py-2.5 transition-all hover:border-primary/30 hover:shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-foreground group-hover:text-primary">
{archive.title}
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{new Date(archive.createdAt).toLocaleString()}</span>
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
<span>{archive.imageCount || 0}张输出</span>
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
<span>{archive.generationMode === "agent_layout" ? "AGENT排版" : "传统生图"}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
className="rounded-md border border-border/50 bg-secondary/50 px-2.5 py-1 text-[10px] font-medium text-secondary-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
onClick={() => loadDetailArchive(archive)}
>
加载
</button>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/50 bg-background text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void deleteDetailArchive(archive.id)}
disabled={deletingDetailArchiveId === archive.id}
title="删除存档"
aria-label="删除存档"
>
{deletingDetailArchiveId === archive.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div
className={cn(
"mt-4 flex items-center justify-between gap-2 rounded-lg border px-4 py-3 text-xs shadow-sm transition-all",
detailValidationIssues.length === 0
? "border-emerald-200 bg-emerald-50/50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400"
: "border-amber-200 bg-amber-50/50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400"
)}
>
{detailValidationIssues.length === 0 ? (
<>
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20">
<CheckCircle2 className="h-3 w-3" />
</div>
<span className="font-medium">校验通过,准备就绪</span>
</div>
<Button
size="sm"
onClick={() => void handleSend()}
disabled={isSubmitting}
className="h-7 bg-emerald-600 px-3 text-[10px] hover:bg-emerald-700 dark:bg-emerald-600 dark:text-white dark:hover:bg-emerald-500"
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
生成中
</>
) : (
<>
立即生成
<ArrowUp className="ml-1.5 h-3 w-3" />
</>
)}
</Button>
</>
) : (
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-600 dark:bg-amber-500/20">
<AlertCircle className="h-3 w-3" />
</div>
<span className="font-medium">
需完善:{detailValidationIssues.join("、")}
</span>
</div>
)}
</div>
{hasGeneratedDetailImages ? (
<div className="mt-3 overflow-hidden rounded-xl border border-violet-200 bg-violet-50/50 p-0 shadow-sm transition-all hover:shadow-md dark:border-violet-500/30 dark:bg-violet-500/10">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-500/20 dark:text-violet-300">
<Sparkles className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-semibold text-violet-700 dark:text-violet-300">
追加评价晒图
</p>
<p className="text-[10px] text-violet-600/70 dark:text-violet-400/70">
新增2条评价 (每条3张图) · 不重跑主图
</p>
</div>
</div>
<button
type="button"
className={cn(
"flex items-center rounded-md px-3 py-1.5 text-xs font-medium shadow-sm transition-all",
isSubmitting
? "cursor-not-allowed bg-violet-100 text-violet-400 dark:bg-violet-500/20"
: "bg-violet-600 text-white hover:bg-violet-700 dark:bg-violet-600 dark:hover:bg-violet-500"
)}
onClick={() => {
void handleGenerateMoreDetailReviews();
}}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
生成中
</>
) : (
<>
立即追加
<ArrowUp className="ml-1.5 h-3 w-3" />
</>
)}
</button>
</div>
</div>
) : null}
</>
)}
</div>
)}
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
"relative px-4 pt-3 pb-12",
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
)}
>
{!isDetailGenerationMode && selectedSkillTag && (
<div className="mb-2 flex">
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-[#f3f4f6] px-3 py-1.5 text-sm font-medium text-[#4b5563] transition-colors hover:bg-[#e9ebef]"
onClick={() => setSelectedSkillTagId(null)}
title="点击移除标签"
>
{selectedSkillTag.iconUrl ? (
<img
src={buildPreviewImageUrl(selectedSkillTag.iconUrl, 40)}
alt={selectedSkillTag.name}
className="h-4 w-4 rounded-sm object-cover"
loading="lazy"
decoding="async"
/>
) : (
<SelectedSkillTagIcon className="h-4 w-4 text-[#8b5cf6]" />
)}
<span>{selectedSkillTag.name}</span>
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
</button>
</div>
)}
{!isDetailGenerationMode && activeComposerMode === "agent" && selectedAgentPreset && (
<div className="mb-2 flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full bg-[#eef2ff] px-3 py-1.5 text-sm font-medium text-[#4338ca]">
<Sparkles className="h-4 w-4" />
<span>{selectedAgentPreset.name}</span>
</div>
{selectedAgentPreset.type === "text" ? (
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472]">
可上传图片做分析,也可直接输入文案需求
</div>
) : hasAgentUploadSlots ? (
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472]">
请上传产品图1、产品图2、模特图
</div>
) : null}
</div>
)}
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
suppressContentEditableWarning
className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-[#9aa1ad]"
data-placeholder={composerPlaceholder}
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement;
clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove());
const textContent = (clone.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
setInput(textContent);
if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
syncAttachmentsWithEditor(target);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// 🆕 详情模式下,Enter 键默认不直接发送,防止误触。除非使用 Meta+Enter 或 Ctrl+Enter(这里先不处理组合键,仅禁用单键发送)
// 或者如果用户习惯了 Enter 发送,可以保持,但考虑到详情生成成本较高,也许需要确认?
// 暂且保持一致,如果用户在详情模式下按 Enter,也触发 handleSend(内部会做校验)
handleSend();
}
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed && range.startOffset === 0) {
// 检查前一个兄弟节点是否是图片标签
const prevSibling = range.startContainer.previousSibling as HTMLElement;
if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) {
e.preventDefault();
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
// 使用 URL 来删除附件,更可靠
removeAttachmentByTagElement(prevSibling);
prevSibling.remove();
}
}
}
}
}}
onPaste={(e) => {
const clipboardItems = Array.from(e.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length > 0) {
e.preventDefault();
void handlePastedImages(imageFiles);
return;
}
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div>
{hasAgentUploadSlots && (
<div className="mb-11 px-4 pb-1">
<div className="flex flex-row gap-2 overflow-x-auto scrollbar-hide">
{agentUploadSlots.map((slot, index) => {
const slotAttachment = attachments[index];
const isUploading = agentSlotUploading === index;
const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
return (
<button
key={`canvas-agent-slot-${slot.slotIndex}-${index}`}
type="button"
onClick={() => {
if (isDisabled) return;
if (fillAgentSlotFromCanvasSelection(index)) {
toast.success(`${slot.label}已从画布填充`);
return;
}
agentSlotInputRefs.current[index]?.click();
}}
className={cn(
"group relative flex h-24 min-w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
!isDisabled && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
isDisabled && "cursor-not-allowed opacity-55"
)}
>
{slotAttachment ? (
<>
<img
src={buildPreviewImageUrl(slotAttachment.url, 256)}
alt={slotAttachment.name || slot.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
}
}}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
{slot.label}
</div>
<div
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
updateAttachmentAtAgentSlot(index, null);
}}
>
x
</div>
</>
) : (
<div className="flex flex-col items-center gap-1 px-1">
<span className="text-xl leading-none">+</span>
<span className="text-center text-xs">{isUploading ? "上传中" : slot.label}</span>
</div>
)}
</button>
);
})}
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选中画布图片时,点击槽位可直接填充,未选中则打开上传。
</div>
</div>
)}
{isVideoModel && videoFrameSlots.length > 0 && (
<div className="mb-11 px-4 pb-1">
<div className="flex flex-row gap-2">
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
const isUploading = videoSlotUploading === index;
const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
return (
<button
key={`canvas-video-slot-${label}-${index}`}
type="button"
onClick={() => {
if (isDisabled) return;
if (fillVideoSlotFromCanvasSelection(index)) {
toast.success(`${label}已从画布填充`);
return;
}
videoSlotInputRefs.current[index]?.click();
}}
className={cn(
"group relative flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
!isDisabled && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
isDisabled && "cursor-not-allowed opacity-55"
)}
>
{slotAttachment ? (
<>
<img
src={buildPreviewImageUrl(slotAttachment.url, 256)}
alt={slotAttachment.name || label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
}
}}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
{label}
</div>
<div
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
updateAttachmentAtVideoSlot(index, null);
}}
>
x
</div>
</>
) : (
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">{isUploading ? "上传中" : label}</span>
</div>
)}
</button>
);
})}
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
已选中画布图片时,点击首帧/尾帧可直接填充;未选中则打开上传。
</div>
</div>
)}
{/* Bottom Toolbar */}
<div className="absolute left-2 bottom-2 right-2 flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-0.5">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
{videoFrameSlots.map((_, index) => (
<input
key={`canvas-video-slot-input-${index}`}
type="file"
accept="image/*"
className="hidden"
ref={(element) => {
videoSlotInputRefs.current[index] = element;
}}
onChange={(event) => {
void handleVideoSlotFileChange(index, event);
}}
/>
))}
{agentUploadSlots.map((_, index) => (
<input
key={`canvas-agent-slot-input-${index}`}
type="file"
accept="image/*"
className="hidden"
ref={(element) => {
agentSlotInputRefs.current[index] = element;
}}
onChange={(event) => {
void handleAgentSlotFileChange(index, event);
}}
/>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className={cn(
"relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium",
activeComposerMode === "video"
? "border border-blue-500 bg-blue-50 text-blue-600 hover:bg-blue-100"
: activeComposerMode === "detail"
? "border border-violet-500 bg-violet-50 text-violet-700 hover:bg-violet-100"
: activeComposerMode === "image"
? "border border-purple-500 bg-purple-50 text-purple-700 hover:bg-purple-100"
: "border border-[#9eb3ff] bg-white text-zinc-700 hover:bg-zinc-50"
)}
>
<span className="flex items-center">
{activeComposerMode === "video" ? (
<Video className="mr-1 h-4 w-4" />
) : activeComposerMode === "detail" ? (
<LayoutGrid className="mr-1 h-4 w-4" />
) : activeComposerMode === "image" ? (
<ImagePlus className="mr-1 h-4 w-4" />
) : (
<Sparkles className="mr-1 h-4 w-4" />
)}
{activeModeLabel}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
sideOffset={10}
className="w-[156px] rounded-xl border border-[#d6d9df] bg-[#f4f5f7]/90 p-1.5 shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
>
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("agent")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
activeComposerMode === "agent" ? "bg-white/80" : "hover:bg-white/70"
)}
>
<Sparkles className="h-4 w-4" />
<span className="flex-1">Agent</span>
{activeComposerMode === "agent" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
</DropdownMenuItem>
{aiDetailGenerationEnabled ? (
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("detail")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
activeComposerMode === "detail" ? "bg-white/80" : "hover:bg-white/70"
)}
>
<LayoutGrid className="h-4 w-4" />
<span className="flex-1">AI详情生成</span>
{activeComposerMode === "detail" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("image")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
activeComposerMode === "image" ? "bg-white/80" : "hover:bg-white/70"
)}
>
<ImagePlus className="h-4 w-4" />
<span className="flex-1">图像生成</span>
{activeComposerMode === "image" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSelectGenerationMode("video")}
className={cn(
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
activeComposerMode === "video" ? "bg-white/80" : "hover:bg-white/70"
)}
>
<Video className="h-4 w-4" />
<span className="flex-1">视频生成</span>
{activeComposerMode === "video" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isDetailGenerationMode && (!isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
>
<Paperclip className="h-4 w-4" />
</Button>
)}
{isVideoModel ? (
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<div className="w-max pr-1">
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
providerType={currentModel?.providerType}
capabilities={currentModel?.videoToolCapability}
/>
</div>
</div>
) : (
<div className="shrink-0">
<ImageParamsBar
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
hideImageCount={activeComposerMode === "agent"}
hideAdvancedOptions={activeComposerMode === "image"}
/>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{activeComposerMode === "image" ? (
<Button
variant="ghost"
size="icon"
title={`深度思考:${deepThinkingEnabled ? "开" : "关"}`}
className={cn(
"h-8 w-8 rounded-full border transition-colors",
deepThinkingEnabled
? "border-[#8ab0ff] bg-[#eef4ff] text-[#3b5bcc] hover:bg-[#e5efff]"
: "border-[#d8dce3] bg-white text-[#6b7280] hover:bg-[#f4f6f8]"
)}
onClick={() => setDeepThinkingEnabled((prev) => !prev)}
disabled={isSubmitting}
>
<Brain className="h-4 w-4" />
</Button>
) : null}
{activeComposerMode !== "detail" ? (
<Select
value={modelSelectValue}
onValueChange={(value) => {
currentModelIdRef.current = value;
setSelectedModelId(value);
if (value !== "auto" || !aiDetailGenerationEnabled) {
setIsDetailGenerationMode(false);
}
}}
>
<SelectTrigger className="h-8 w-8 rounded-full border border-[#cfd4dc] bg-white p-0 justify-center [&>svg]:hidden">
<span className="h-6 w-6 overflow-hidden rounded-full bg-white flex items-center justify-center">
{selectDisplayModel?.avatarUrl ? (
<img
src={buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64)}
alt={selectDisplayModel.displayName || "模型头像"}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== selectDisplayModel.avatarUrl) {
event.currentTarget.src = selectDisplayModel.avatarUrl || "";
}
}}
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#4b5563]">
{(selectDisplayModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
</SelectTrigger>
{renderModelSelectContent()}
</Select>
) : null}
<Button
size="icon"
className={cn(
"h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
isDetailGenerationMode
? canSend
? "bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white shadow-md hover:shadow-lg hover:opacity-90 dark:from-violet-500 dark:to-fuchsia-500"
: "bg-muted text-muted-foreground hover:bg-muted/80"
: canSend
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
onClick={handleSend}
disabled={isSubmitting || !canSend}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isDetailGenerationMode ? (
<Sparkles className="h-4 w-4" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
{/* 🆕 标签悬停预览弹窗 - 固定显示在标签上方 */}
{tagPreview && (() => {
// 计算弹窗位置
const previewWidth = 240;
const previewHeight = 280;
const padding = 16;
// 简化:直接使用标签位置,在标签上方居中显示
// tagPreview.x 是标签中心的x坐标
// tagPreview.y 是标签顶部的y坐标
let left = tagPreview.x - previewWidth / 2;
let top = tagPreview.y - previewHeight - 12;
// 确保不超出视口左边界
if (left < padding) {
left = padding;
}
// 确保不超出视口右边界
if (typeof window !== 'undefined' && left + previewWidth > window.innerWidth - padding) {
left = window.innerWidth - previewWidth - padding;
}
// 如果上方空间不够,显示在标签下方
if (top < padding) {
top = tagPreview.y + 36; // 标签下方
}
// 确保不超出视口底部
if (typeof window !== 'undefined' && top + previewHeight > window.innerHeight - padding) {
top = window.innerHeight - previewHeight - padding;
}
// 最终确保 top 不为负
if (top < padding) {
top = padding;
}
// 使用 Portal 渲染到 body,避免 backdrop-blur 影响 fixed 定位
return createPortal(
<div
data-tag-preview="true"
className="fixed z-[10001] bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-border/50 overflow-hidden animate-in fade-in zoom-in-95 duration-150"
style={{
left: left,
top: top,
width: previewWidth,
}}
onMouseEnter={() => {
// 鼠标进入预览弹窗,取消隐藏
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
}}
onMouseLeave={() => {
// 鼠标离开预览弹窗,隐藏
setTagPreview(null);
}}
>
{/* 图片全貌 - 使用 inline-block + 居中,让标注点相对于图片本身定位 */}
<div className="flex justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="relative inline-block">
<img
src={buildPreviewImageUrl(tagPreview.url, 720)}
alt={tagPreview.name}
className="block max-w-full max-h-[180px] h-auto"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== tagPreview.url) {
event.currentTarget.src = tagPreview.url;
}
}}
/>
{/* 如果是物品标签,显示标注位置 */}
{tagPreview.pickedObject && (
<>
{/* 标注点 */}
<div
className="absolute pointer-events-none"
style={{
left: `${tagPreview.pickedObject.clickX * 100}%`,
top: `${tagPreview.pickedObject.clickY * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 序号标记 */}
<div className="relative flex items-center justify-center">
{/* 脉冲动画背景 */}
<div className="absolute w-6 h-6 rounded-full bg-blue-500/30 animate-ping" />
{/* 主标记点 */}
<div className="relative w-5 h-5 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M12 2v20M2 12h20" />
</svg>
</div>
</div>
</div>
{/* 物品名称标签 */}
<div
className="absolute bg-blue-500 text-white text-xs px-1.5 py-0.5 rounded shadow-lg whitespace-nowrap"
style={{
left: `${tagPreview.pickedObject.clickX * 100}%`,
top: `${tagPreview.pickedObject.clickY * 100}%`,
transform: 'translate(-50%, -140%)',
}}
>
{tagPreview.pickedObject.objectName}
</div>
</>
)}
</div>
</div>
{/* 底部信息 */}
<div className="px-3 py-2 bg-zinc-50 dark:bg-zinc-800/50 border-t border-border/30">
<div className="text-xs font-medium text-foreground/80 truncate">
{tagPreview.pickedObject ? tagPreview.pickedObject.objectName : tagPreview.name}
</div>
{tagPreview.pickedObject && (
<div className="text-xs text-muted-foreground">
{tagPreview.pickedObject.objectPosition}
</div>
)}
</div>
</div>,
document.body
);
})()}
<AlertDialog
open={Boolean(insufficientPointsState)}
onOpenChange={(open) => {
if (!open) setInsufficientPointsState(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>积分不足</AlertDialogTitle>
<AlertDialogDescription>
{insufficientPointsState
? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。`
: "当前积分不足,请先充值后再发送生图需求。"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>稍后再说</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setInsufficientPointsState(null);
if (typeof window !== "undefined") {
window.location.href = "/recharge";
}
}}
>
去充值
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}