"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; import useSWR from "swr"; import { Send, Loader2, ImagePlus, Paperclip, X, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, AlertCircle, CheckCircle2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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 { extractImageUrlsFromText } from "@/lib/image-url"; import type { Attachment, ChatMessage } from "@/lib/types"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; 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"; // 标签悬停预览信息 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; }; } interface CanvasChatPanelProps { chatId: string; initialMessages?: ChatMessage[]; onClose?: () => void; onOpenFileList?: () => void; } // 建议卡片数据 - 已移除 const SUGGESTIONS: { title: string; desc: string; image: string }[] = []; // 获取图片尺寸(保留原图分辨率 + 画布显示尺寸) 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[] { return extractImageUrlsFromText(text); } 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 normalizeVideoParamsForProvider( params: VideoGenerationParams | null | undefined, providerType?: string ): VideoGenerationParams { const isFalVideo = providerType === "fal-video"; const base: VideoGenerationParams = { mode: isFalVideo ? "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 (!["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 ): { min: number; max: number } { if (!params) { return { min: 0, max: 0 }; } const isFalVideo = providerType === "fal-video"; 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 }; }; } interface CanvasImageAttachment { url: string; name: string; contentType: string; } interface CanvasChatSession { id: string; title: string; createdAt: number; updatedAt: number; } type DetailBriefForm = { 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"; }; type DetailSectionKey = "required" | "strategy" | "assets"; type DetailPlanResponse = { 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; visualPrompt?: string; fabe?: { b?: string; f?: string; a?: string; e?: string; }; }>; skillFlow?: { name?: string; version?: string; steps?: string[]; }; qualityChecks?: { passed?: boolean; issues?: string[]; }; model?: { id: string; displayName: string; providerType: string; }; }; const DETAIL_TARGET_WIDTH = 790; const DETAIL_TARGET_HEIGHT = 1300; 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" }, ]; 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 5; const value = Number(matched[1]); if (!Number.isFinite(value)) return 5; return Math.min(12, Math.max(5, Math.round(value))); } function buildDetailResultMarkdown(params: { 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; fabe?: { b?: string; f?: string; a?: string; e?: string; }; }>; }): string { const lines: string[] = []; lines.push("### AI详情生成完成"); 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?.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(""); }); return lines.join("\n").trim(); } export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) { const [input, setInput] = useState(""); const [attachments, setAttachments] = useState([]); const [selectedModelId, setSelectedModelId] = useState(""); const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false); const [isDetailGenerating, setIsDetailGenerating] = useState(false); const [detailSections, setDetailSections] = useState>({ required: true, strategy: false, assets: 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 [detailBrief, setDetailBrief] = useState({ productName: "", targetAudience: "", mainUser: "", usageScenario: "", triggerMoment: "", coreNeed: "", riskConcern: "", expectedResult: "", coreSellingPoint: "", positioningStatement: "", userVoice: "", competitorReference: "", priceBand: "", brandTone: "", bannedWords: "", }); const canvasId = useMemo(() => toRawCanvasChatId(chatId), [chatId]); const [activeChatId, setActiveChatId] = useState(chatId); const [chatSessions, setChatSessions] = useState([]); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); const textareaRef = useRef(null); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(null); const pendingLaunchRef = useRef(null); const sessionMenuRef = useRef(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); // 🆕 保存图片参数到 localStorage const handleImageParamsChange = (params: ImageGenerationParams) => { setImageParams(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 => ({ ...prev, ...parsed })); } } catch (e) { console.error("Failed to load image params:", e); } }, []); const handleVideoParamsChange = (params: VideoGenerationParams) => { setVideoParams(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) { setVideoParams(JSON.parse(saved)); } } 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(() => { 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 updateNode = useCanvasStore((state) => state.updateNode); const nodes = useCanvasStore((state) => state.nodes); 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); // 从 API 加载历史消息 const { data: historyData } = useSWR<{ messages: ChatMessage[]; }>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher); // 获取选中的图片节点 const selectedImageNodes = nodes.filter( n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src ); // 🆕 辅助函数:在 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(() => { pendingLaunchRef.current = null; 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"), })) : []; 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) => ({ ...prev, ...parsed.imageParams, })); } setTimeout(() => { 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, }; } 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]); // 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签 useEffect(() => { if (pendingChatAttachments.length > 0) { // 获取当前附件列表 const currentAttachments = [...attachments]; const existingUrls = new Set(currentAttachments.map(a => a.url)); // 过滤出需要添加的图片(不在当前附件列表中的) const toAdd = pendingChatAttachments.filter(a => !existingUrls.has(a.url)); if (toAdd.length > 0) { // 插入图片标签到编辑器 toAdd.forEach((att, i) => { insertImageTagInEditor(att.url, att.name, currentAttachments.length + i); }); // 更新附件列表 setAttachments([...currentAttachments, ...toAdd]); // 聚焦到输入框 setTimeout(() => { (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); } // 清空待添加列表 clearPendingChatAttachments(); } }, [pendingChatAttachments, attachments, insertImageTagInEditor, clearPendingChatAttachments]); // 🆕 监听 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; providerType: string; supportsVision?: boolean; supportsImageGen?: boolean; capabilities?: string[] | null; }>; agentModel?: { id: string; displayName: string; avatarUrl?: string | null; providerType: string; } | null; }>("/api/models", fetcher); // 获取网站设置(包含广告横幅配置和画布设置) const { data: siteSettings } = useSWR<{ bannerEnabled?: boolean; bannerText?: string; bannerLink?: string | null; canvasEnabled?: boolean; aiDetailGenerationEnabled?: boolean; canvasDefaultModelId?: string | null; canvasAutoModeDefaultModelId?: string | null; }>("/api/site-settings", fetcher); const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true; const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "qianwen-video", "fal-video"]); const visualModels = modelsData?.models?.filter( (model) => model.supportsImageGen || VIDEO_PROVIDER_TYPES.has(model.providerType) ) || []; // Agent模式(自动路由)模型选项 const SMART_AUTO_MODEL = { id: "auto", modelId: "auto", displayName: "Agent模式", avatarUrl: modelsData?.agentModel?.avatarUrl || null, providerType: "auto", supportsImageGen: true, }; // 所有可选模型(Agent模式 + 可视化模型) const allModels = [SMART_AUTO_MODEL, ...visualModels]; const currentModel = allModels.find((model) => model.id === selectedModelId); const isVideoModel = Boolean(currentModel && VIDEO_PROVIDER_TYPES.has(currentModel.providerType)); const videoFrameSlots = useMemo(() => { if (!isVideoModel || !videoParams) return [] as string[]; if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[]; if (videoParams.mode === "first-frame") return ["首帧"] as string[]; return [] as string[]; }, [isVideoModel, videoParams]); const firstImageModelId = useMemo( () => allModels.find( (model) => model.id !== "auto" && !VIDEO_PROVIDER_TYPES.has(model.providerType) )?.id || null, [allModels] ); const firstVideoModelId = useMemo( () => allModels.find((model) => VIDEO_PROVIDER_TYPES.has(model.providerType))?.id || null, [allModels] ); const activeComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => { if (isDetailGenerationMode) return "detail"; if (selectedModelId === "auto") return "agent"; if (isVideoModel) return "video"; return "image"; }, [isDetailGenerationMode, isVideoModel, selectedModelId]); const activeModeLabel = activeComposerMode === "video" ? "视频" : activeComposerMode === "detail" ? "AI详情" : activeComposerMode === "image" ? "生图" : "Agent"; // 默认选择模型(根据系统设置) useEffect(() => { if (!selectedModelId && siteSettings) { // 使用系统设置的默认模型,如果没有设置则使用Agent模式 const defaultModel = siteSettings.canvasDefaultModelId || "auto"; setSelectedModelId(defaultModel); } }, [selectedModelId, siteSettings]); useEffect(() => { if (!aiDetailGenerationEnabled && isDetailGenerationMode) { setIsDetailGenerationMode(false); } }, [aiDetailGenerationEnabled, isDetailGenerationMode]); useEffect(() => { if (isDetailGenerationMode) { setAttachments([]); setDetailSections({ required: true, strategy: false, assets: 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 imageParamsRef = useRef(imageParams); const videoParamsRef = useRef(videoParams); const isVideoModelRef = useRef(isVideoModel); useEffect(() => { currentModelIdRef.current = selectedModelId; }, [selectedModelId]); useEffect(() => { imageParamsRef.current = imageParams; }, [imageParams]); useEffect(() => { videoParamsRef.current = videoParams; }, [videoParams]); useEffect(() => { isVideoModelRef.current = isVideoModel; }, [isVideoModel]); useEffect(() => { if (!isVideoModel) return; const normalized = normalizeVideoParamsForProvider(videoParams, currentModel?.providerType); if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) { setVideoParams(normalized); } }, [isVideoModel, currentModel?.providerType, 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 (currentModel.id !== "auto") { setLastImageModelId(currentModel.id); } }, [currentModel, isDetailGenerationMode, isVideoModel]); const handleSelectGenerationMode = useCallback( (mode: "agent" | "image" | "video" | "detail") => { if (mode === "agent") { setSelectedModelId("auto"); setIsDetailGenerationMode(false); return; } if (mode === "detail") { if (!aiDetailGenerationEnabled) { toast.error("AI详情生成功能未开启"); return; } setSelectedModelId("auto"); setIsDetailGenerationMode(true); return; } if (mode === "image") { const preferredImageModelId = allModels.find( (model) => model.id === lastImageModelId && model.id !== "auto" && !VIDEO_PROVIDER_TYPES.has(model.providerType) )?.id || firstImageModelId; if (!preferredImageModelId) { toast.error("当前没有可用的图像模型"); return; } 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; } setSelectedModelId(preferredVideoModelId); setIsDetailGenerationMode(false); }, [ aiDetailGenerationEnabled, allModels, firstImageModelId, firstVideoModelId, 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 { 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 = detailSlots.slice(0, 5).filter((item) => Boolean(item?.url)).length; if (productCount < 5) issues.push(`产品图不足(${productCount}/5)`); if (!detailSlots[5]?.url) issues.push("缺少模特图(1/1)"); return issues; }, [detailSlots, normalizedDetailBrief]); const toggleDetailSection = useCallback((key: DetailSectionKey) => { setDetailSections((prev) => ({ ...prev, [key]: !prev[key] })); }, []); 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 { 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, lastMessage: request.messages.at(-1), }); const hiddenPrompt = hiddenPromptRef.current || undefined; const autoContext = autoContextRef.current || undefined; hiddenPromptRef.current = null; autoContextRef.current = null; return { body: { id: rawId, message: request.messages.at(-1), selectedChatModel: currentModelIdRef.current, selectedVisibilityType: "private", videoParams: isVideoModelRef.current ? videoParamsRef.current || undefined : undefined, imageParams: isVideoModelRef.current ? undefined : imageParamsRef.current || undefined, hiddenPrompt, autoContext, ...request.body, }, }; }, }), onData: (dataPart: any) => { // 处理后端返回的自定义消息 if (dataPart.type === "data-appendMessage") { try { const messageData = typeof dataPart.data === 'string' ? JSON.parse(dataPart.data) : dataPart.data; setMessages((prevMessages) => { if (prevMessages.some(m => m.id === messageData.id)) { return prevMessages; } const newMessage: any = { id: messageData.id, role: messageData.role, content: messageData.parts?.[0]?.text || "", createdAt: new Date(), parts: messageData.parts || [], attachments: [], }; return [...prevMessages, newMessage]; }); } catch (e) { console.error("[CanvasChat] Failed to parse data-appendMessage:", e); } } }, onError: (error) => { toast.error(error.message || "生成失败"); }, }); 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, })); if (textContent) { parts.push({ type: "text" as const, text: textContent }); } sendMessage({ role: "user", parts, }); pendingLaunchRef.current = null; setInput(""); setAttachments([]); clearSelection(); const editableDiv = textareaRef.current as HTMLDivElement | null; if (editableDiv) { editableDiv.innerHTML = ""; } }, [selectedModelId, status, sendMessage, clearSelection]); // 当历史消息加载完成后,更新消息列表 const hasLoadedHistory = useRef(false); useEffect(() => { if (historyData && !hasLoadedHistory.current) { hasLoadedHistory.current = true; setMessages(historyData.messages || []); console.log('[CanvasChat] 已加载历史消息:', historyData.messages?.length || 0); } }, [historyData, setMessages]); // 监听消息变化,提取图片并添加到画布 // 只处理新生成的图片,不处理历史消息中的图片 const processedMessageIds = useRef>(new Set()); const isInitialLoad = useRef(true); useEffect(() => { hasLoadedHistory.current = false; processedMessageIds.current.clear(); isInitialLoad.current = true; hiddenPromptRef.current = null; autoContextRef.current = null; pendingLaunchRef.current = null; setMessages([]); setInput(""); setAttachments([]); setIsSessionMenuOpen(false); 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(() => { // 首次加载历史消息时,只记录已处理的消息 ID,不添加图片到画布 // (画布数据应该从 canvasData 中加载,而不是从消息中提取) if (isInitialLoad.current) { messages.forEach(message => { if (message.role === 'assistant') { processedMessageIds.current.add(message.id); } }); isInitialLoad.current = false; return; } // 只处理新消息 messages.forEach(async (message, messageIndex) => { if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) { const content = message.parts?.map((p: any) => p.text || '').join('') || ''; const imageUrls = extractImageUrls(content); const videoUrls = extractVideoUrls(content); const generationPrompt = findNearestUserPrompt(messages, messageIndex); if (imageUrls.length > 0 || videoUrls.length > 0) { processedMessageIds.current.add(message.id); let addedImageCount = 0; let addedVideoCount = 0; if (imageUrls.length > 0) { // 检查画布中是否已存在相同 URL 的图片 const existingUrls = new Set( nodes.filter(n => n.type === 'image' && (n.data as any)?.src) .map(n => (n.data as any).src) ); // 只添加不存在的图片 const newUrls = imageUrls.filter(url => !existingUrls.has(url)); // 异步获取每张图片的尺寸并添加到画布 for (let index = 0; index < newUrls.length; index++) { const url = newUrls[index]; // 获取图片原始尺寸 const dimensions = await getImageDimensions(url); // 计算位置(避免重叠,根据图片宽度动态调整间距) const existingNodes = nodes.length + index; const spacing = 20; // 图片间距 const x = 100 + (existingNodes % 3) * (dimensions.width + spacing); const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing); addNode({ type: 'image', position: { x, y }, size: { width: dimensions.width, height: dimensions.height }, data: { src: url, status: 'completed', prompt: generationPrompt || '对话生成', metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, generationPrompt: generationPrompt || undefined, sourceMessageId: message.id, }, }, connections: [], }); addedImageCount += 1; } } if (videoUrls.length > 0) { const existingVideoUrls = new Set( nodes.filter(n => n.type === 'video' && (n.data as any)?.src) .map(n => (n.data as any).src) ); const newVideoUrls = videoUrls.filter(url => !existingVideoUrls.has(url)); for (let index = 0; index < newVideoUrls.length; index++) { const url = newVideoUrls[index]; const existingNodes = nodes.length + index; 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} 添加到画布`); } } } }); }, [messages, addNode, 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); // 获取最近的图片附件(支持多张图片与名称同步) const getRecentImageAttachments = useCallback((): CanvasImageAttachment[] => { const result: CanvasImageAttachment[] = []; const addedUrls = new Set(); const addAttachment = (item: CanvasImageAttachment) => { if (!item.url || addedUrls.has(item.url)) return; addedUrls.add(item.url); result.push(item); }; const getNodeName = (node: any, index: number) => { return ( node?.data?.title || node?.data?.prompt || `图片 ${index + 1}` ); }; // 1. 优先使用画布中选中的图片(支持多选) if (selectedImageNodes.length > 0) { selectedImageNodes.forEach((node, index) => { const src = (node.data as any)?.src; if (src) { addAttachment({ url: src, name: getNodeName(node, index), contentType: "image/jpeg", }); } }); if (result.length > 0) { console.log("[CanvasChat] 使用选中的图片:", result.length, "张"); return result; } } // 2. 历史对话最近一轮图片 for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i]; const msgAttachments: CanvasImageAttachment[] = []; if (msg.role === "user" && (msg as any).parts) { for (const part of (msg as any).parts) { if (part.type === "file" && part.url && part.mediaType?.startsWith("image/")) { msgAttachments.push({ url: part.url, name: part.name || "图片", contentType: part.mediaType || "image/jpeg", }); } } } if (msgAttachments.length > 0) { msgAttachments.forEach(addAttachment); console.log("[CanvasChat] 使用历史对话图片:", result.length, "张"); return result; } } // 3. 画布中最近图片 const imageNodes = nodes.filter((n) => n.type === "image" && (n.data as any)?.src); if (imageNodes.length > 0) { imageNodes.slice(-5).forEach((node, index) => { addAttachment({ url: (node.data as any).src, name: getNodeName(node, index), contentType: "image/jpeg", }); }); console.log("[CanvasChat] 使用画布图片:", result.length, "张"); } return result; }, [messages, nodes, selectedImageNodes]); // 🆕 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 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 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 fetch("/api/files/upload", { method: "POST", body: formData, }); if (!response.ok) { throw new Error("上传失败"); } const data = await response.json(); const imageUrl = data.url as string; updateAttachmentAtVideoSlot(slotIndex, { url: imageUrl, name: file.name, contentType: file.type || "image/jpeg", }); 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 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 fetch("/api/files/upload", { method: "POST", body: formData, }); if (!response.ok) { throw new Error("上传失败"); } const data = await response.json(); const imageUrl = String(data.url || "").trim(); if (!imageUrl) { throw new Error("未获取到图片地址"); } updateDetailSlotAttachment(slotIndex, { url: imageUrl, name: file.name, contentType: file.type || "image/jpeg", }); toast.success(`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}上传成功`); } catch (_error) { toast.error("图片上传失败"); } finally { setDetailSlotUploading(null); event.currentTarget.value = ""; } }, [updateDetailSlotAttachment] ); const handleDetailGeneration = useCallback(async () => { if (isDetailGenerating) return; const brief = normalizedDetailBrief; if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) { toast.error("请先补充 AI详情生成必填项:主购买人群、需求场景、核心需求"); return; } const productAttachments = detailSlots .slice(0, 5) .filter((item): item is PickedObjectAttachment => Boolean(item?.url)); if (productAttachments.length < 5) { toast.error("请先上传 5 张产品图后再执行 AI详情生成"); return; } const modelAttachment = detailSlots[5]; if (!modelAttachment?.url) { toast.error("请上传 1 张人物模特图后再执行 AI详情生成"); return; } const userText = input .replace(/\u200b/g, " ") .replace(/\s+/g, " ") .trim(); const basePrompt = brief.productName ? `${brief.productName}详情页生成` : "电商详情页生成"; const detailPrompt = userText || `${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}`; const detailCount = extractDetailFrameCount(detailPrompt); const assistantMessageId = generateUUID(); const skillLogicPreview = [ "### AI详情生成", "我们将基于《详情升级文案方案》执行以下技能流程:", "1. 定位卡:锁定人群/场景/触发时刻/风险与期望结果", "2. 需求拆解:卖点转买点,确定核心需求优先级", "3. 黄金五屏:核心卖点 -> 差异强化 -> 证据收口", "4. FABE文案:每屏输出利益/特征/优势/证据", "5. A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾", "6. 一致性约束:固定790x1300 + 统一字体层级 + 统一排版风格", "", "正在规划分镜提示词...", ].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 = `模特图固定策略:涉及人物场景时必须使用第6张模特图同一人物,不得替换人物。`; const detailBriefText = [ "【AI详情生成输入】", 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, `输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`, ] .filter(Boolean) .join("\n"); setIsDetailGenerating(true); setDetailProgress({ status: "running", stage: "Agent 正在解析需求并规划详情结构...", current: 0, total: Math.max(1, detailCount + 1), }); try { 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, referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 6), referenceImageNotes, modelImageUrl: modelAttachment.url, outputLanguage: /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh", brief, }), }); const planResult = await planResponse.json(); if (!planResponse.ok || !planResult?.success) { throw new Error(planResult?.error || "详情分镜规划失败"); } const planData = (planResult.data || {}) as DetailPlanResponse; 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 totalSteps = prompts.length + 1; const qualityIssues = Array.isArray(planData.qualityChecks?.issues) ? planData.qualityChecks?.issues.filter(Boolean) : []; setDetailProgress({ status: "running", stage: planData.qualityChecks?.passed === false ? `结构质检提示:${qualityIssues[0] || "已自动切换为稳定方案"}` : "结构质检通过,开始逐屏生成", current: 1, total: totalSteps, }); const modelForGeneration = selectedModelId !== "auto" ? selectedModelId : siteSettings?.canvasAutoModeDefaultModelId || siteSettings?.canvasDefaultModelId || undefined; const generatedImages: Array<{ originalUrl: string; thumbnailUrl: string }> = []; for (let index = 0; index < prompts.length; index += 1) { const screen = screenSpecs[index]; const useModelForScene = Boolean(screen?.useModel); const promptText = String(screen?.visualPrompt || "").trim() || prompts[index]!; setDetailProgress({ status: "running", stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`, current: index + 2, 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}` : "", ] .filter(Boolean) .join(";"); setMessages((prev) => prev.map((message) => message.id === assistantMessageId ? { ...message, parts: [ { type: "text", text: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 张...`, }, ], } : message, ), ); const generationPrompt = [ promptText, styleDirective ? `全局样式约束:${styleDirective}` : "", screen?.screenType ? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)` : "", useModelForScene ? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。` : "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。", screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "", screen?.title ? `本屏标题:${screen.title}` : "", screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "", 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} 像素。`, "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。", ] .filter(Boolean) .join("\n\n"); const styleAnchorUrl = index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null; const generationReferenceImages = [ ...productAttachments.map((item) => item.url), ...(useModelForScene ? [modelAttachment.url] : []), ...(styleAnchorUrl ? [styleAnchorUrl] : []), ].filter(Boolean); const generationResponse = await fetch("/api/canvas/generate/image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: generationPrompt, model: modelForGeneration, aspectRatio: "9:16", imageCount: 1, negativePrompt: imageParams.negativePrompt, referenceImage: productAttachments[0]?.url, referenceImages: generationReferenceImages, }), }); const generationResult = await generationResponse.json(); if (!generationResponse.ok || !generationResult.success || !generationResult.data?.images?.length) { throw new Error(generationResult.error || `第 ${index + 1} 张生成失败`); } const generated = generationResult.data.images[0]!; generatedImages.push(generated); const dimensions = await getImageDimensions( generated.originalUrl || generated.thumbnailUrl ); const spacing = 24; const itemWidth = DETAIL_TARGET_WIDTH; const itemHeight = DETAIL_TARGET_HEIGHT; const column = index % 3; const row = Math.floor(index / 3); const x = 120 + column * (itemWidth + spacing); const y = 120 + row * (itemHeight + spacing); addNode({ type: "image", position: { x, y }, size: { width: itemWidth, height: itemHeight }, data: { src: generated.originalUrl || generated.thumbnailUrl, status: "completed", prompt: promptText, title: `详情图-${index + 1}`, metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, generationPrompt, 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 || "", detailGlobalStyle: planData.globalStyle || null, }, }, connections: [], }); } const finalMarkdown = buildDetailResultMarkdown({ summary: planData.summary, images: generatedImages, prompts, skillFlow: planData.skillFlow, qualityChecks: planData.qualityChecks, globalStyle: planData.globalStyle, screens: screenSpecs, }); setMessages((prev) => prev.map((message) => message.id === assistantMessageId ? { ...message, parts: [ { type: "text", text: finalMarkdown, }, ], } : message, ), ); setDetailProgress({ status: "done", stage: `已完成 ${generatedImages.length} 张详情图`, current: totalSteps, total: totalSteps, }); toast.success(`AI详情生成完成,共 ${generatedImages.length} 张`); } catch (error) { const errMsg = error instanceof Error ? error.message : "AI详情生成失败"; 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, detailSlots, imageParams.negativePrompt, input, isDetailGenerating, normalizedDetailBrief, selectedModelId, setMessages, siteSettings?.canvasAutoModeDefaultModelId, siteSettings?.canvasDefaultModelId, ]); // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { if (isDetailGenerationMode && aiDetailGenerationEnabled) { await handleDetailGeneration(); return; } if (!input.trim() && attachments.length === 0) return; if (!selectedModelId) { toast.error("请选择一个模型"); return; } // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; // 🆕 如果没有附件,自动使用最近的图片(支持多张) let effectiveAttachments = [...attachments]; const videoImageLimit = isVideoModel ? getVideoImageLimit(videoParams, currentModel?.providerType) : null; if ( effectiveAttachments.length === 0 && input.trim() && (!videoImageLimit || videoImageLimit.max > 0) && videoFrameSlots.length === 0 ) { const recentAttachments = getRecentImageAttachments(); if (recentAttachments.length > 0) { // 自动添加所有最近的图片作为附件 recentAttachments.forEach((attachment) => { effectiveAttachments.push({ url: attachment.url, name: attachment.name, contentType: attachment.contentType, }); }); console.log('[CanvasChat] 自动使用最近图片:', recentAttachments.length, '张'); } } 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, 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 = "请处理这张图片"; } // 🆕 隐藏提示词(不在 UI 中展示) const hiddenSegments: string[] = []; 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 }); } // 使用与 multimodal-input 相同的格式发送消息 sendMessage({ role: "user", parts: parts, }); // 清空状态 setInput(""); setAttachments([]); // 🆕 清空画布选中状态(防止 useEffect 重新添加标签) clearSelection(); // 🆕 清空 contenteditable div 的内容 const editableDiv = textareaRef.current as HTMLDivElement | null; if (editableDiv) { editableDiv.innerHTML = ''; console.log('[CanvasChat] 已清空输入框和选中状态'); } }, [ input, attachments, selectedModelId, sendMessage, getRecentImageAttachments, clearSelection, buildAutoContext, isVideoModel, videoParams, videoFrameSlots.length, currentModel?.providerType, selectedImageNodes, isDetailGenerationMode, aiDetailGenerationEnabled, handleDetailGeneration, ]); // 处理文件选择 - 上传后立即添加到画布 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 fetch('/api/files/upload', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error('上传失败'); } const data = await response.json(); const imageUrl = data.url; // 记录第一张图片的 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, }]; }); // 获取图片原始尺寸后添加到画布 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, 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 }); if (firstImageUrl && !hasSetCanvasTitle.current) { hasSetCanvasTitle.current = true; console.log('[CanvasChat] 开始调用 auto-title API...'); // 异步调用 AI 识别图片主体并更新画布 (async () => { try { // 调用 auto-title API 识别图片主体 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); // 提取纯 UUID(去掉 canvas- 前缀) 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: title, thumbnail: firstImageUrl, }), }); console.log('[CanvasChat] 画布更新响应:', updateResponse.status); if (updateResponse.ok) { console.log('[CanvasChat] 画布已自动命名为:', title); toast.success(`画布已命名为: ${title}`); } else { console.error('[CanvasChat] 画布更新失败'); } } else { console.error('[CanvasChat] auto-title API 请求失败:', response.status); } } catch (error) { console.error('[CanvasChat] 自动命名失败:', error); } })(); } 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}`); })); }, []); const isLoading = status === 'streaming' || status === 'submitted'; const isSubmitting = isLoading || isDetailGenerating; const hasInputOrAttachments = Boolean(input.trim() || attachments.length > 0); const detailProductCount = detailSlots.slice(0, 5).filter((item) => Boolean(item?.url)).length; const detailModelReady = Boolean(detailSlots[5]?.url); const detailProgressPercent = detailProgress.total > 0 ? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100)) : 0; const hasDetailFallbackAttachment = !isDetailGenerationMode && !hasInputOrAttachments && getRecentImageAttachments().length > 0; const canSend = isDetailGenerationMode ? detailValidationIssues.length === 0 && !isDetailGenerating : hasInputOrAttachments || hasDetailFallbackAttachment; const currentVideoImageLimit = isVideoModel ? getVideoImageLimit(videoParams, currentModel?.providerType) : null; const showVideoAttachmentButton = Boolean( isVideoModel && currentVideoImageLimit && currentVideoImageLimit.max > 0 && videoFrameSlots.length === 0 ); const lastUserIndexForMarkers = messages.findLastIndex(m => m.role === "user"); const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? messages.slice(lastUserIndexForMarkers + 1) : messages; 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 && !!currentModel; const loadingImageCount = imageParams.imageCount || 1; const renderModelSelectContent = () => (
{allModels.map((model) => ( {model.avatarUrl ? ( {model.displayName} ) : ( {model.id === "auto" ? "A" : model.displayName.slice(0, 1)} )} {model.displayName} {selectedModelId === model.id ? ( ✓ ) : 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]); return (
{/* Header - Minimal with Icons */}
{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 ( ); }) )}
)}
{/* 消息列表 */}
{messages.length === 0 && (
{/* Greeting */}

Hi, 我是你的AI设计师

让我们开始今天的创作吧!

{/* Suggestions - Removed */} {/* Refresh - Removed */} {/* Banner - 从系统设置中获取 */} {(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
{ if (siteSettings?.bannerLink) { window.open(siteSettings.bannerLink, '_blank'); } }} >
{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}
e.stopPropagation()} />
)}
)} {messages.map((message) => { // 🆕 清理消息中的内部标记并检查是否有实际内容 const rawParts = message.parts || []; const cleanedParts = rawParts.map((part: any) => { if (part.type === 'text' && part.text) { return { ...part, text: part.text .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .trim() }; } return part; }); // 检查清理后是否有实际内容(更严格的检查) let hasContent = false; for (const part of cleanedParts) { if (part.type === 'text' && part.text && part.text.trim().length > 0) { hasContent = true; break; } if ((part.type === 'file' || part.type === 'image') && part.url) { hasContent = true; break; } } // 同时检查附件 const attachmentsList = (message as any).attachments || []; if (attachmentsList.length > 0) { hasContent = true; } // 没有内容则不渲染(包括空 parts 的情况) if (!hasContent) { console.log('[CanvasChat] 跳过空消息:', message.id); return null; } // 使用清理后的消息 const cleanedMessage = { ...message, parts: cleanedParts }; const assistantTextParts = message.role === "assistant" ? cleanedParts.filter((part: any) => part.type === "text" && part.text) : []; const hasAssistantImages = assistantTextParts.some((part: any) => extractImageUrls(part.text || "").length > 0 ); const hasAssistantText = assistantTextParts.some((part: any) => { const textWithoutImages = (part.text || "") .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "") .trim(); return textWithoutImages.length > 0; }); const isImageOnlyAssistant = message.role === "assistant" && hasAssistantImages && !hasAssistantText; return (
{/* 头像 */} {message.role === 'user' ? ( <> ) : ( <> )} {/* 消息气泡 */}
{message.role === 'user' ? ( // 🆕 用户消息:使用图片标签样式(类似竞品,按 parts 原始顺序渲染)
{/* 按 parts 原始顺序渲染图片标签和文字 */} {cleanedMessage.parts?.map((part: any, idx: number) => { if (part.type === 'file' && part.url) { // 图片标签 return ( {part.name || 'Image'} ); } if (part.type === 'text' && part.text) { // 文字(去掉标注提示前缀) let text = part.text.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '').trim(); if (!text) return null; return ( {text} ); } return null; })} {/* 如果有 attachments 但没有 file parts,也显示 */} {(!cleanedMessage.parts?.some((p: any) => p.type === 'file') && (message as any).attachments?.length > 0) && (message as any).attachments.map((att: any, idx: number) => ( {att.name || 'Image'} )) }
) : ( // AI 消息:保持原样式
{/* 消息内容 */}
{cleanedMessage.parts?.map((part: any, i: number) => { if (part.type === 'text') { // 提取并显示图片 const imageUrls = extractImageUrls(part.text || ''); const textWithoutImages = (part.text || '') .replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, '') .trim(); return (
{textWithoutImages && {textWithoutImages}} {imageUrls.length > 0 && (
{imageUrls.map((url, idx) => (
setFullscreenPreview({ url })} onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); setFullscreenPreview({ url }); }} />
))}
)}
); } return null; })}
)}
); })} {shouldShowImageLoading && ( )} {isLoading && !shouldShowImageLoading && (
正在思考中...
)}
{fullscreenPreview && createPortal(
setFullscreenPreview(null)} > {fullscreenPreview.alt e.stopPropagation()} />
点击空白处或按 Esc 关闭
, document.body )} {/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
{isDetailGenerationMode && (

AI详情生成工作台(PC)

固定规格 {DETAIL_TARGET_WIDTH} x {DETAIL_TARGET_HEIGHT},先分析后生成,保持整套详情图风格统一。

{detailProgress.status === "running" ? "执行中" : detailProgress.status === "done" ? "已完成" : detailProgress.status === "error" ? "异常" : "待启动"}
{detailProgress.stage || "待开始:请先补齐输入信息与素材。"} {detailProgress.current}/{detailProgress.total || 0}
{detailSections.required ? (
updateDetailBriefField("targetAudience", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="主购买人群 *" /> updateDetailBriefField("usageScenario", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="需求场景 *" /> updateDetailBriefField("coreNeed", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="核心需求 *" /> updateDetailBriefField("productName", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="产品名称(选填)" /> updateDetailBriefField("mainUser", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="主使用者(选填)" /> updateDetailBriefField("triggerMoment", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="触发时刻(选填)" /> updateDetailBriefField("riskConcern", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="用户顾虑(选填)" /> updateDetailBriefField("expectedResult", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="期望结果(选填)" /> updateDetailBriefField("coreSellingPoint", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 xl:col-span-2 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="核心卖点关键词(选填)" /> updateDetailBriefField("positioningStatement", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 xl:col-span-2 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="一句话定位(选填)" />
) : null}
{detailSections.strategy ? (
updateDetailBriefField("priceBand", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="价格带(选填)" /> updateDetailBriefField("brandTone", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="品牌语气(选填)" /> updateDetailBriefField("competitorReference", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 xl:col-span-2 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="竞品对标(选填)" /> updateDetailBriefField("bannedWords", event.target.value)} className="h-8 rounded-lg border border-violet-200/80 bg-white/90 px-2 text-xs outline-none focus:border-violet-400 xl:col-span-2 dark:border-violet-500/30 dark:bg-zinc-900/60" placeholder="禁用表达(选填,逗号分隔)" />