"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 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[]; }; type ImageGenerationStatusStreamPayload = { phase?: "queued" | "processing" | "finalizing" | "failed" | "completed"; providerType?: string; modelName?: string; imageCount?: number; }; interface CanvasChatPanelProps { chatId: string; initialMessages?: ChatMessage[]; onClose?: () => void; onOpenFileList?: () => void; } type VideoStoryboardStreamData = | { stage: "detecting"; provider?: string; message?: string; } | { stage: "start"; provider?: string; taskId: string; totalShots: number; confidence?: number; sourceImageUrl?: string; shots?: Array<{ shotIndex: number; tileImageUrl?: 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; const metadata = ((data.metadata as any) || {}) as Record; 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 { const urlKeys = new Set(); 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(); 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 { for (let i = assistantIndex - 1; i >= 0; i -= 1) { const message = messages[i]; if (message?.role !== "user") continue; return collectMessageImageUrlKeys(message); } return new Set(); } 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[] { const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim; const markdownUrls: string[] = []; const seenMarkdownUrls = new Set(); 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(); const videoTagPattern = /]+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(//gi, "") .replace(//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 ): 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
{displayText}
; } 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; 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[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 = { 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): string { return `canvas-chat-sessions:${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 }: CanvasChatPanelProps) { const { data: session } = useSession(); const [input, setInput] = useState(""); const [attachments, setAttachments] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false); const [detailGenerationMode, setDetailGenerationMode] = useState("classic"); const [isDetailGenerating, setIsDetailGenerating] = useState(false); const [detailSections, setDetailSections] = useState>({ 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>( () => DETAIL_SLOT_CONFIGS.map(() => null) ); const [detailSlotUploading, setDetailSlotUploading] = useState(null); const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState(null); const [detailArchives, setDetailArchives] = useState([]); const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState(null); const [detailBrief, setDetailBrief] = useState({ 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(chatId); const [chatSessions, setChatSessions] = useState([]); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const [selectedSkillTagId, setSelectedSkillTagId] = useState(null); const [messageViewportKey, setMessageViewportKey] = useState(0); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); const textareaRef = useRef(null); const detailModeSwitchRef = useRef(false); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(null); const pendingLaunchRef = useRef(null); const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0); const hasInitializedChatStateRef = useRef(false); const sessionMenuRef = useRef(null); const historyScrollTimerRef = useRef | null>(null); // 🆕 标签悬停预览状态 const [tagPreview, setTagPreview] = useState(null); const previewTimeoutRef = useRef(null); const [fullscreenPreview, setFullscreenPreview] = useState<{ url: string; alt?: string; } | null>(null); // 🆕 图片参数状态 const [imageParams, setImageParams] = useState({ aspectRatio: "auto", imageSize: "2K", imageCount: 1, }); const [videoParams, setVideoParams] = useState(null); const [videoSlotUploading, setVideoSlotUploading] = useState(null); const [lastImageModelId, setLastImageModelId] = useState(null); const [lastVideoModelId, setLastVideoModelId] = useState(null); const [lastConversationModelId, setLastConversationModelId] = useState(null); const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false); const [analysisCollapseState, setAnalysisCollapseState] = useState>({}); const [typedAssistantSections, setTypedAssistantSections] = useState>({}); const [activeTypingSections, setActiveTypingSections] = useState>({}); const [insufficientPointsState, setInsufficientPointsState] = useState<{ balance: number; expectedCost: number; unitCost: number; imageCount: number; } | null>(null); const [streamImageGenerationState, setStreamImageGenerationState] = useState(null); const imageParamsRef = useRef(imageParams); const videoParamsRef = useRef(videoParams); const deepThinkingEnabledRef = useRef(deepThinkingEnabled); const imageAutoTitleCacheRef = useRef>(new Map()); const sendGuardRef = useRef<{ inFlight: boolean; signature: string; timestamp: number; }>({ inFlight: false, signature: "", timestamp: 0, }); const storyboardNodeMapRef = useRef>>({}); const releaseSendGuard = useCallback(() => { sendGuardRef.current.inFlight = false; }, []); const shouldBlockDuplicateSend = useCallback((signature: string) => { const now = Date.now(); const previous = sendGuardRef.current; return ( previous.signature === signature && now - previous.timestamp < 4500 ); }, []); 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; 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(); const fallbackSession: CanvasChatSession = { id: chatId, title: "对话 1", createdAt: now, updatedAt: now, }; try { const key = getSessionStorageKey(canvasId); 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 === chatId); const nextSessions = hasCurrent ? sanitized : [fallbackSession, ...sanitized]; const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt); setChatSessions(sorted); setActiveChatId(chatId); localStorage.setItem(key, JSON.stringify(sorted)); } catch (error) { console.error("[CanvasChat] Failed to load chat sessions:", error); setChatSessions([fallbackSession]); setActiveChatId(chatId); } }, [canvasId, chatId]); 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), JSON.stringify(chatSessions) ); } catch (error) { console.error("[CanvasChat] Failed to save chat sessions:", error); } }, [canvasId, 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(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( 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( 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 = ` ${shortName} `; // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 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 = ` ${objectName} · ${objectPosition} `; // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 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 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 | 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 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(); 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 === "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]); // 监听 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 (isVideoModel && videoFrameSlots.length > 0) { const slotCount = videoFrameSlots.length; const currentSlots: Array = 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, isVideoModel, videoFrameSlots.length, attachments, insertImageTagInEditor, clearPendingChatAttachments, ]); const firstImageModelId = useMemo( () => imageOnlyModels[0]?.id || null, [imageOnlyModels] ); const firstVideoModelId = useMemo( () => videoOnlyModels[0]?.id || null, [videoOnlyModels] ); 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 activeModeLabel = activeComposerMode === "video" ? "视频" : activeComposerMode === "detail" ? "AI详情" : activeComposerMode === "image" ? "生图" : "Agent"; // 默认选择模型(根据系统设置) useEffect(() => { if (!selectedModelId && siteSettings) { // 使用系统设置的默认模型,如果没有设置则使用Agent模式 const defaultModel = siteSettings.canvasDefaultModelId || "auto"; currentModelIdRef.current = defaultModel; setSelectedModelId(defaultModel); } }, [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 (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 (!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; const metadata = ((data.metadata as any) || {}) as Record; 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() || "对话生成"; 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, }, }, 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, }, 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, stop, } = useChat({ 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), 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") { setStreamImageGenerationState(null); return; } setStreamImageGenerationState({ phase: payload.phase || "processing", providerType: payload.providerType, modelName: 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("") ) : false; const hasImageGenEndMarker = Array.isArray(messageData.parts) ? messageData.parts.some( (part: any) => part?.type === "text" && typeof part?.text === "string" && part.text.includes("") ) : false; if (hasImageGenStartMarker) { setStreamImageGenerationState((prev) => ({ phase: "processing", providerType: prev?.providerType || (activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType), modelName: prev?.modelName || (activeComposerMode === "agent" ? "智能处理" : currentModel?.displayName || undefined), imageCount: prev?.imageCount || Math.max(1, Number(imageParamsRef.current?.imageCount || 1)), })); } if (hasImageGenEndMarker) { setStreamImageGenerationState(null); } const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; const normalizedAttachments = Array.isArray(messageData.attachments) ? messageData.attachments : []; 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 shotNodeMap: Record = {}; 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) % 3; const row = Math.floor((shotIndex - 1) / 3); const nodeId = addNode({ type: "video", position: { x: startX + col * (cardWidth + gapX), y: startY + row * (cardHeight + gapY), }, size: { width: cardWidth, height: cardHeight }, data: { src: "", status: "generating", prompt: shotData?.prompt || `分镜 ${shotIndex} 生成中`, coverImage: shotData?.tileImageUrl || "", }, connections: [], }); shotNodeMap[shotIndex] = nodeId; } storyboardNodeMapRef.current[taskId] = shotNodeMap; toast.message(`九宫格视频任务已开始(${totalShots} 段)`); 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; updateNode(nodeId, { data: { ...currentData, src: videoUrl, status: "completed", }, } 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; 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) => { 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(); } }, [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(""); useEffect(() => { if (historyScrollTimerRef.current) { clearTimeout(historyScrollTimerRef.current); historyScrollTimerRef.current = null; } if (!historyData || !rawChatId) return; if (status === "streaming" || status === "submitted") 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; 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>(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; const metadata = ((data.metadata as any) || {}) as Record; 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(); const videoUrlSet = new Set(); 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>( (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; const metadata = ((data.metadata as any) || {}) as Record; 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(); 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(); 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 = 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 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 handleVideoSlotFileChange = useCallback( async (slotIndex: number, event: React.ChangeEvent) => { 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 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) => { 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) => { setDetailArchives((prev) => prev.map((item) => item.id === archiveId ? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() } : item ) ); }, [] ); const createDetailArchiveInDatabase = useCallback( async (archive: DetailArchive): Promise => { 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; }): Promise => { 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; } = {} ) => { const nodeId = detailPlaceholderNodeIds[index]; if (!nodeId) return; const storeState = useCanvasStore.getState(); const currentNode = storeState.getNode(nodeId); const currentData = ((currentNode?.data as any) || {}) as Record; const currentMetadata = ((currentData.metadata as any) || {}) as Record; 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; 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; 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 () => { if (sendGuardRef.current.inFlight || status === "streaming" || status === "submitted") { return; } if (isDetailGenerationMode && aiDetailGenerationEnabled) { sendGuardRef.current = { inFlight: true, signature: `detail:${activeChatId}:${Date.now()}`, timestamp: Date.now(), }; try { await handleDetailGeneration(); } finally { releaseSendGuard(); } 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]; 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 && selectedSkillTag) { textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`; } // 🆕 隐藏提示词(不在 UI 中展示) const hiddenSegments: string[] = []; if (selectedSkillTag?.prompt) { hiddenSegments.push( `【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}` ); } 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(sendSignature)) { toast.message("检测到重复发送,已自动忽略"); return; } sendGuardRef.current = { inFlight: true, signature: sendSignature, timestamp: Date.now(), }; setStreamImageGenerationState(null); // 使用与 multimodal-input 相同的格式发送消息 try { sendMessage({ role: "user", parts: parts, }); } catch (error) { releaseSendGuard(); 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, input, attachments, selectedSkillTag, selectedModelId, sendMessage, clearSelection, buildAutoContext, buildContextPayload, isVideoModel, videoParams, videoFrameSlots.length, currentModel?.providerType, selectedImageNodes, isDetailGenerationMode, aiDetailGenerationEnabled, handleDetailGeneration, ensureImagePointsBeforeSend, isVideoModel, releaseSendGuard, 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 (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); }, [ attachments, insertImageTagInEditor, isVideoModel, maybeAutoSetCanvasTitle, nodes.length, updateAttachmentAtVideoSlot, uploadImageFileAndAddToCanvas, videoFrameSlots.length, ]); // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; let firstImageUrl: string | null = null; // 记录本次上传开始时的节点数量,用于计算新图片位置 const baseNodeCount = nodes.length; let uploadIndex = 0; 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(); 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 composerPlaceholder = isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : 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; 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("") ) ); const hasGenerationEnd = messagesAfterLastUser.some(msg => msg.role === "assistant" && msg.parts?.some( (part: any) => part.type === "text" && part.text?.includes("") ) ); 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((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*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\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 = () => (
{selectableModels.map((model) => ( {model.avatarUrl ? ( {model.displayName} { if (event.currentTarget.src !== model.avatarUrl) { event.currentTarget.src = model.avatarUrl || ""; } }} /> ) : ( {model.id === "auto" ? "A" : model.displayName.slice(0, 1)} )} {model.displayName} {modelSelectValue === model.id ? ( ✓ ) : null} ))} {selectableModels.length === 0 ? (
暂无可用模型
) : null}
); 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, }; setChatSessions((prevSessions) => [newSession, ...prevSessions]); setActiveChatId(newChatSessionId); toast.success("已新建聊天窗口"); }, [chatSessions.length]); const switchToSession = useCallback((sessionId: string) => { if (!sessionId || sessionId === activeChatId) { setIsSessionMenuOpen(false); return; } setActiveChatId(sessionId); setIsSessionMenuOpen(false); toast.success("已切换聊天窗口"); }, [activeChatId]); 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 (
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */} {detailSlotPickerIndex !== null && (

正在从画布选择图片

请点击画布中的任意图片以填入 “{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}”

)} {/* Header - Minimal with Icons */}

{displayCanvasName}

{isSessionMenuOpen && (
聊天窗口记录
{chatSessions.length === 0 ? (
暂无记录
) : ( chatSessions.map((session, index) => { const isActive = session.id === activeChatId; const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); return ( ); }) )}
)}
{/* 消息列表 */} {isDetailEmptyState ? (
) : (
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 && (
{/* Greeting */}

Hi {greetingUserName ? ( <> , {greetingUserName} {greetingPeriodText} ) : ( {greetingPeriodText} )}

开始创作吧! {Number.isFinite(totalGeneratedImages) ? ( <> {" "}累计为你生成了{" "} {totalGeneratedImages.toLocaleString()} {" "} 张图。 ) : null}

{canvasSkillTags.length > 0 && (

{canvasSkillTagsTitle}

{canvasSkillTags.map((tag) => { const isActive = selectedSkillTag?.id === tag.id; const TagIcon = getCanvasSkillIconComponent(tag.iconName); return ( ); })}
)} {/* Refresh - Removed */}
)} {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 (
{showDateSeparator && messageTimestamp ? (
{formatMessageDateLabel(messageTimestamp)}
) : null}
{/* 消息区域 */}
{message.role === 'user' ? (
{(() => { 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( { if (event.currentTarget.src !== fileUrl) { event.currentTarget.src = fileUrl; } }} /> {part.name || 'Image'} ); return; } if (part.type === 'text' && part.text) { const text = String(part.text) .replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '') .trim(); if (!text) return; renderedUserParts.push( {text} ); } }); 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( { if (event.currentTarget.src !== att.url) { event.currentTarget.src = att.url; } }} /> {att.name || 'Image'} ); }); } if (renderedUserParts.length === 0) { const fallbackText = extractUserTextPrompt(message as any) || (isVideoModel ? "已发送视频生成请求" : "已发送请求"); renderedUserParts.push( {fallbackText} ); } return renderedUserParts; })()}
) : ( <>
{/* 消息内容 */}
{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) => (
{text}
); return (
{analysisSection ? (
{analysisSection.before ? renderPlainText(analysisSection.before, `before-${i}`) : null} setAnalysisCollapseState((prev) => ({ ...prev, [analysisToggleKey]: open, })) } > 图片分析 {analysisSection.after ? renderPlainText(analysisSection.after, `after-${i}`) : null}
) : plainText ? ( ) : null} {imageUrls.length > 0 && (
{mediaModelName ? (
{mediaModelName}
) : null}
{imageUrls.map((url, idx) => (
{ if (event.currentTarget.src !== url) { event.currentTarget.src = url; } }} onClick={() => setFullscreenPreview({ url })} onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); setFullscreenPreview({ url }); }} />
))}
{shouldShowDoneSummary ? (

{CANVAS_IMAGE_DONE_SUMMARY}

) : null}
)} {videoUrls.length > 0 && (
0 ? "mt-3" : "mt-0")}> {videoUrls.map((url, idx) => ( ))}
)}
); } return null; })}
{showModelMeta ? ( {displayAssistantModelName} ) : null} {showVoteActions ? ( <> ) : null}
)}
); })} {shouldRenderImageLoading && ( )} {isLoading && !shouldRenderImageLoading && (
分析中
)}
)} {fullscreenPreview && createPortal(
setFullscreenPreview(null)} > {fullscreenPreview.alt e.stopPropagation()} />
点击空白处或按 Esc 关闭
, document.body )} {shouldShowCanvasBanner ? (
{ if (siteSettings?.bannerLink) { window.open(siteSettings.bannerLink, "_blank"); } }} >
{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}
e.stopPropagation()} />
) : null} {/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
{isDetailGenerationMode && (

AI详情生成工作台

PC端

全链路智能生成 · {DETAIL_TARGET_WIDTH}x{DETAIL_TARGET_HEIGHT}px

{detailProgress.status === "running" ? "生成中" : detailProgress.status === "done" ? "已完成" : detailProgress.status === "error" ? "异常" : "待启动"}
{isDetailWorkbenchCollapsed ? ( ) : ( <>
{detailProgress.stage || "准备就绪"} {detailProgress.current}/{detailProgress.total || 0} 步骤
{!detailProgress.stage && (

请先补齐必填信息与素材,然后点击下方发送按钮开始生成。

)}

生成路线

{detailGenerationMode === "classic" ? "当前:传统生图" : "当前:AGENT排版"}
{DETAIL_GENERATION_MODE_OPTIONS.map((option) => { const selected = detailGenerationMode === option.value; return ( ); })}
{/* Required Info */} 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" >
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="建议填写" />
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="建议填写" />
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="必填" />
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="必填" />
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="必填" />
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="选填" />
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="选填" />
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="选填" />
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="选填" />
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="选填" />
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="选填" />
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="选填" />
{/* Strategy Info */} 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" >
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="选填" />
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="选填" />
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="选填" />
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="逗号分隔" />