"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 type { DetailUiProtocol } from "@/lib/detail-ui-protocol"; import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol"; import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2"; import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder"; import { buildDetailTextLayerNodes, buildDetailTextLayerNodesFromReviewPatch, buildDetailTextLayerNodesFromSurfaceScreen, collectManagedDetailTextLayerNodeIds, } from "@/lib/detail-ui-renderer"; import { buildUiScreenDocumentFromDetailUiDsl } from "@/lib/ui-screen/detail-adapter"; import type { UiScreenDocument } from "@/lib/ui-screen/schema"; import { applyUiSurfacePatch, type UiSurfacePatch, type UiSurfaceSnapshot, } from "@/lib/ui-surface-protocol"; import useSWR, { useSWRConfig } from "swr"; import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react"; import { Button } from "@/components/ui/button"; 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 { canvasService } from "@/services/canvas"; import { inferOriginalImageUrl, } from "@/lib/image-url"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { getGenerationCreditCostFromImageGenConfig, getImageCreditCostFromConfig, getImageModelCreditDiscountLabel, } from "@/lib/ai/image-model-pricing"; import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability"; import type { ChatMessage } from "@/lib/types"; import type { AgentPreset, AgentWorkflowComposerContext, } from "@/types/agent"; import type { CanvasNode } from "@/types/canvas"; import type { CanvasAgentAnnotationRef, CanvasAgentContext, CanvasAgentNodeRef, CanvasAgentSlotRef, CanvasAgentNodeType, } from "@/types/canvas/agent-protocol"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; import { DEFAULT_CANVAS_SKILL_TAGS_TITLE, normalizeCanvasSkillTags, type CanvasSkillTag, } from "@/lib/canvas-skill-tags"; import { isCanvasImageReadyForAi, isCanvasImageUploadPending, } from "@/lib/canvas/local-upload-state"; import { computeCanvasAgentPanViewport, resolveCanvasViewOperationTargetNodeIds, } from "@/lib/canvas/agent-view-operations"; import { getCanvasAgentOperationErrorMessage } from "@/lib/canvas/agent-operation-errors"; 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 { Response } from "@/components/elements/response"; import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel"; import { DetailSurfaceInspectorPanel } from "@/components/canvas/DetailSurfaceInspectorPanel"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { getCanvasToolbarItem, normalizeCanvasToolbarConfig, type CanvasToolbarConfig, type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { AGENT_PROGRESS_STEPS, type AgentProgressStreamPayload, type AgentTargetLockRefPreview, type AgentTargetLockStreamPayload, type CanvasOperationStreamPayload, type CanvasViewOperationStreamPayload, type CanvasMediaStreamPayload, getAgentProgressFallbackTitle, getAgentTargetLockTitle, getAgentTargetResolutionLabel, getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, type ImageGenerationStatusStreamPayload, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; import { type DetailArchive, type DetailBriefForm, DETAIL_BUNDLE_SLOT_INDEXES, DETAIL_FIELD_EXAMPLES, DETAIL_GENERATION_MODE_OPTIONS, type DetailGenerationMode, DETAIL_MODEL_SLOT_INDEX, type DetailPlanResponse, type DetailPlanScreenSpec, DETAIL_PRODUCT_SLOT_INDEXES, DETAIL_REVIEW_COUNT, DETAIL_REVIEW_IMAGE_COUNT, DETAIL_REVIEW_IMAGE_HEIGHT, DETAIL_REVIEW_IMAGE_WIDTH, DETAIL_REVIEW_REGEN_COUNT, type DetailReviewGroup, type DetailReviewPlan, type DetailScreenReviewResult, type DetailSectionKey, DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS, DETAIL_SLOT_CONFIGS, DETAIL_STYLE_BOARD_HEIGHT, DETAIL_STYLE_BOARD_SPACING, DETAIL_STYLE_BOARD_WIDTH, DETAIL_TARGET_HEIGHT, DETAIL_TARGET_WIDTH, DETAIL_TYPOGRAPHY_LOCK, type DetailUiReviewSnapshot, type DetailUiValidationSnapshot, extractDetailFrameCount, extractFirstNumber, extractFontWeight, normalizeDetailGenerationMode, sanitizeDetailImageModelPrompt, } from "@/components/canvas/chat-panel/canvas-detail-config"; import { buildDetailResultMarkdown, buildDetailReviewAppendMarkdown, buildDetailReviewPlans, } from "@/components/canvas/chat-panel/canvas-detail-markdown"; import { createDetailArchiveSnapshot, patchDetailArchiveList, prependDetailArchive, replaceSavedDetailArchive, sanitizeDetailArchiveList, } from "@/components/canvas/chat-panel/canvas-detail-archive"; import { applyDetailSlotAttachment, fillDetailSlotAttachments, getDetailSlotTargetIndexes, } from "@/components/canvas/chat-panel/canvas-detail-slots"; import { buildCanvasUploadedImageAttachment, getCanvasUploadImageTitle, normalizeUploadedAssetId, validateCanvasImageUploadFile, } from "@/components/canvas/chat-panel/canvas-upload-helpers"; import { clampChatRequestText, deriveSessionTitleFromMessages, extractUserTextPrompt, findNearestUserPrompt, getSessionRootStorageKey, getSessionStorageKey, isGenericCanvasPrompt, isValidChatHttpUrl, isValidChatUuid, MAX_CHAT_AUTO_CONTEXT_LENGTH, MAX_CHAT_HIDDEN_PROMPT_LENGTH, MAX_CHAT_TEXT_PART_LENGTH, normalizeChatAttachmentName, normalizeChatMediaType, type PickedObjectAttachment, sanitizeChatAttachment, toRawCanvasChatId, } from "@/components/canvas/chat-panel/canvas-chat-session"; import { buildCanvasHistoryMessagesSignature, buildDownloadFilename, extractAssistantImageUrls, extractCanvasModelName, extractImageUrls, extractVideoUrls, formatMessageDateLabel, getAssistantImageNodeIdentityKey, getCanvasAssistantMessagesAfterTargetUser, getMessageDayKey, getMessageTimestamp, getNearestUserImageUrlKeys, getNearestUserImageUrls, hasCanvasImageMarkdown, hasCanvasLikelyGenerationErrorMessage, messageHasAssistantMedia, normalizeImageIdentityUrl, normalizeMediaUrl, sortCanvasMessagesByCreatedAt, stripCanvasModelRoutingLines, stripMediaMarkup, triggerBrowserDownload, } from "@/components/canvas/chat-panel/canvas-chat-media"; // 标签悬停预览信息 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; selectedAgentPresetId?: string | null; chatScope?: "canvas" | "agent"; workflowContext?: AgentWorkflowComposerContext | null; presentation?: "panel" | "workspace"; agentEnabled?: boolean; workspaceVariant?: "canvas" | "video"; canvasToolbarConfig?: CanvasToolbarConfig; } type VideoStoryboardStreamData = | { stage: "detecting"; provider?: string; message?: string; } | { stage: "start"; provider?: string; taskId: string; totalShots: number; gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3"; rows?: number; cols?: number; confidence?: number; sourceImageUrl?: string; shots?: Array<{ shotIndex: number; tileImageUrl?: string; prompt?: string; }>; } | { stage: "hd_shot_completed"; provider?: string; taskId: string; shotIndex: number; imageUrl: string; prompt?: string; } | { stage: "hd_shot_failed"; provider?: string; taskId: string; shotIndex: number; error?: string; } | { stage: "awaiting_confirmation"; provider?: string; taskId: string; totalShots: number; gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3"; rows?: number; cols?: number; message?: string; shots?: Array<{ shotIndex: number; tileImageUrl?: string; previewImageUrl?: string; prompt?: string; }>; } | { stage: "shot_completed"; provider?: string; taskId: string; shotIndex: number; modelId?: string; videoUrl: string; text?: string; } | { stage: "shot_failed"; provider?: string; taskId: string; shotIndex: number; error?: string; } | { stage: "completed"; provider?: string; taskId: string; totalShots?: number; successCount?: number; failedCount?: number; } | { stage: "failed"; provider?: string; taskId: string; error?: string; } | { stage: "shot_retry"; provider?: string; taskId: string; shotIndex: number; attempt?: number; modelId?: string; error?: string; }; // 建议卡片数据 - 已移除 const SUGGESTIONS: { title: string; desc: string; image: string }[] = []; const CANVAS_COMPOSER_PREFIX_DEFAULT = "让「由壹点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; }); } function buildPreviewImageUrl(url: string, width: number): string { return buildImageProxyUrl(url, { width, quality: 86, format: "webp", }); } 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 = 10 * 60 * 1000; const CANVAS_GENERATION_NODE_GAP = 56; const CANVAS_GENERATION_STACK_GAP = 28; const CANVAS_GENERATION_COLLISION_GAP = 32; function hashCanvasChatValue(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) | 0; } return Math.abs(hash).toString(36); } function buildCanvasMediaFallbackMessageId(chatId: string, urls: string[]) { const key = urls.map((url) => normalizeMediaUrl(url)).filter(Boolean).join("|"); return `canvas-media-${chatId}-${hashCanvasChatValue(key || String(Date.now()))}`; } function buildCanvasMediaMarkdown(params: { imageUrls: string[]; videoUrls: string[]; }) { return [ ...params.imageUrls.map((url) => `![Generated Image](${url})`), ...params.videoUrls.map((url) => `[生成视频](${url})`), ].join("\n\n"); } function isInternalCanvasImageGenerationFailureText(parts: Array) { const text = parts .filter((part) => part?.type === "text" && typeof part?.text === "string") .map((part) => part.text) .join("\n") .trim(); if (!text) return false; const normalized = stripCanvasModelRoutingLines(stripMediaMarkup(text)).trim(); if (!normalized) return false; if (extractAssistantImageUrls(normalized).length > 0 || extractVideoUrls(normalized).length > 0) { return false; } return ( /^(?:生成图片失败|图片生成失败)[,,::\s]*(?:未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片)?$/i.test( normalized ) || (/未返回可用图片/i.test(normalized) && /未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|模型响应未包含可用图片/i.test( normalized )) ); } function findCanvasImageAnchorNode( nodes: CanvasNode[], params: { selectedNodes?: CanvasNode[]; sourceImageUrls?: string[]; } = {} ) { const sourceKeys = new Set( (params.sourceImageUrls || []) .map((url) => normalizeImageIdentityUrl(url)) .filter(Boolean) ); if (sourceKeys.size > 0) { const matchedNode = nodes.find((node) => { if (node.type !== "image") return false; const src = String((node.data as any)?.src || "").trim(); return sourceKeys.has(normalizeImageIdentityUrl(src)); }); if (matchedNode) return matchedNode; } return params.selectedNodes?.find((node) => node.type === "image") || null; } function getAnchoredGenerationPosition(params: { anchorNode?: CanvasNode | null; existingNodes?: CanvasNode[]; excludeNodeIds?: string[]; size: { width: number; height: number }; index?: number; fallbackNodeCount?: number; viewport?: { x: number; y: number; zoom: number }; }) { const index = params.index || 0; const anchorNode = params.anchorNode || null; const size = { width: Math.max(1, Number(params.size?.width || 300)), height: Math.max(1, Number(params.size?.height || 300)), }; const excludedIds = new Set(params.excludeNodeIds || []); const overlapsExistingNode = (position: { x: number; y: number }) => { const padding = CANVAS_GENERATION_COLLISION_GAP; const left = position.x - padding; const top = position.y - padding; const right = position.x + size.width + padding; const bottom = position.y + size.height + padding; return (params.existingNodes || []).some((existingNode) => { if (!existingNode || excludedIds.has(existingNode.id)) return false; const nodeWidth = Math.max(1, Number(existingNode.size?.width || 300)); const nodeHeight = Math.max(1, Number(existingNode.size?.height || 300)); const nodeLeft = Number(existingNode.position?.x || 0); const nodeTop = Number(existingNode.position?.y || 0); const nodeRight = nodeLeft + nodeWidth; const nodeBottom = nodeTop + nodeHeight; return !( right <= nodeLeft || left >= nodeRight || bottom <= nodeTop || top >= nodeBottom ); }); }; const firstFreePosition = (candidates: Array<{ x: number; y: number }>) => { return candidates.find((candidate) => !overlapsExistingNode(candidate)) || null; }; if (anchorNode) { const anchorWidth = Number(anchorNode.size?.width || 300); const anchorHeight = Number(anchorNode.size?.height || 300); const stackStep = Math.max( 96, Math.min(size.height, anchorHeight) + CANVAS_GENERATION_STACK_GAP ); const anchorLeft = Number(anchorNode.position?.x || 0); const anchorTop = Number(anchorNode.position?.y || 0); const anchorRight = anchorLeft + anchorWidth; const anchorBottom = anchorTop + anchorHeight; const gap = CANVAS_GENERATION_NODE_GAP; const preferred = [ { x: anchorRight + gap, y: anchorTop + index * stackStep, }, { x: anchorLeft - size.width - gap, y: anchorTop + index * stackStep, }, { x: anchorLeft + index * (size.width + CANVAS_GENERATION_STACK_GAP), y: anchorBottom + gap, }, { x: anchorLeft + index * (size.width + CANVAS_GENERATION_STACK_GAP), y: anchorTop - size.height - gap, }, ]; const preferredFree = firstFreePosition(preferred); if (preferredFree) return preferredFree; const horizontalStep = size.width + gap; const verticalStep = size.height + gap; const spiralCandidates: Array<{ x: number; y: number }> = []; for (let ring = 1; ring <= 10; ring += 1) { const rightX = anchorRight + gap + (ring - 1) * horizontalStep; const leftX = anchorLeft - size.width - gap - (ring - 1) * horizontalStep; const downY = anchorBottom + gap + (ring - 1) * verticalStep; const upY = anchorTop - size.height - gap - (ring - 1) * verticalStep; spiralCandidates.push( { x: rightX, y: anchorTop }, { x: leftX, y: anchorTop }, { x: anchorLeft, y: downY }, { x: anchorLeft, y: upY }, { x: rightX, y: downY }, { x: rightX, y: upY }, { x: leftX, y: downY }, { x: leftX, y: upY } ); } const spiralFree = firstFreePosition(spiralCandidates); if (spiralFree) return spiralFree; return preferred[0]!; } const viewport = params.viewport || { x: 0, y: 0, zoom: 1 }; const zoom = Math.max(0.1, viewport.zoom || 1); if (typeof window !== "undefined") { const centerPosition = { x: (-viewport.x + window.innerWidth * 0.52) / zoom - size.width / 2 + index * 28, y: (-viewport.y + window.innerHeight * 0.46) / zoom - size.height / 2 + index * 28, }; if (!overlapsExistingNode(centerPosition)) return centerPosition; } const fallbackNodeCount = params.fallbackNodeCount || 0; const fallbackBase = { x: 100 + (fallbackNodeCount % 3) * (size.width + CANVAS_GENERATION_COLLISION_GAP), y: 100 + Math.floor(fallbackNodeCount / 3) * (size.height + CANVAS_GENERATION_COLLISION_GAP), }; if (!overlapsExistingNode(fallbackBase)) return fallbackBase; for (let row = 0; row < 20; row += 1) { for (let col = 0; col < 6; col += 1) { const candidate = { x: 100 + col * (size.width + CANVAS_GENERATION_COLLISION_GAP), y: 100 + row * (size.height + CANVAS_GENERATION_COLLISION_GAP), }; if (!overlapsExistingNode(candidate)) return candidate; } } return fallbackBase; } function getCenteredReplacementPosition( placeholderNode: CanvasNode, size: { width: number; height: number } ) { const width = Math.max(1, Number(size.width || 300)); const height = Math.max(1, Number(size.height || 300)); const placeholderWidth = Math.max( 1, Number(placeholderNode.size?.width || width) ); const placeholderHeight = Math.max( 1, Number(placeholderNode.size?.height || height) ); return { x: Number(placeholderNode.position?.x || 0) + placeholderWidth / 2 - width / 2, y: Number(placeholderNode.position?.y || 0) + placeholderHeight / 2 - height / 2, }; } 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; renderMarkdown?: boolean; onActiveChange?: (typingKey: string, active: boolean) => void; onComplete?: (typingKey: string) => void; }; function CanvasTypewriterText({ text, typingKey, enabled, className, renderMarkdown = false, 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]); if (renderMarkdown) { return {displayText}; } return
{displayText}
; } function normalizeCanvasAgentText(value: string): string { return String(value || "") .replace(/\r\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function hasCanvasRenderableMarkdown(value: string): boolean { const text = String(value || ""); if (!text) return false; return ( /(^|\n)\s*#{1,6}\s+/.test(text) || /\*\*[^*]+\*\*/.test(text) || /(^|\n)\s*[-*+]\s+/.test(text) || /(^|\n)\s*\d+\.\s+/.test(text) || /\[[^\]]+\]\([^)]+\)/.test(text) ); } 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: Partial | null | undefined, providerType?: string, capability?: VideoToolCapabilityContract | null ): VideoGenerationParams { if (capability) { const modeOptions = capability.modeOptions || []; const modeSet = new Set(modeOptions.map((item) => item.mode)); const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video"; const merged = { mode: fallbackMode, resolution: (capability.defaults?.resolution || "720p") as VideoGenerationParams["resolution"], ratio: (capability.defaults?.ratio || "16:9") as VideoGenerationParams["ratio"], duration: Number(capability.defaults?.duration) || 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"]; } const scopedOptions = getVideoToolOptionsForMode(capability, merged.mode); const ratioOptions = scopedOptions.ratioOptions || []; const durationOptions = scopedOptions.durationOptions || []; const resolutionOptions = scopedOptions.resolutionOptions || []; const ratioSet = new Set(ratioOptions); const durationSet = new Set(durationOptions); const resolutionSet = new Set(resolutionOptions); const fallbackRatio = scopedOptions.defaults?.ratio || ratioOptions[0] || capability.defaults?.ratio || "16:9"; const fallbackDuration = Number(scopedOptions.defaults?.duration) || durationOptions[0] || Number(capability.defaults?.duration) || 5; const fallbackResolution = scopedOptions.defaults?.resolution || resolutionOptions[0] || capability.defaults?.resolution || "720p"; if (!ratioSet.has(merged.ratio)) { merged.ratio = fallbackRatio as VideoGenerationParams["ratio"]; } if (!durationSet.has(Number(merged.duration))) { merged.duration = fallbackDuration; } else { merged.duration = Number(merged.duration) || fallbackDuration; } if (resolutionOptions.length > 0 && !resolutionSet.has(merged.resolution)) { merged.resolution = fallbackResolution as VideoGenerationParams["resolution"]; } else if (resolutionOptions.length === 0) { 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 }; } } type MessageVote = { chatId: string; messageId: string; isUpvoted: boolean; }; interface CanvasChatSession { id: string; title: string; createdAt: number; updatedAt: number; } export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList, selectedAgentPresetId, chatScope = "canvas", workflowContext = null, presentation = "panel", agentEnabled = true, workspaceVariant = "canvas", canvasToolbarConfig: rawCanvasToolbarConfig, }: CanvasChatPanelProps) { const isWorkspacePresentation = presentation === "workspace"; const isVideoWorkspace = workspaceVariant === "video"; const propCanvasToolbarConfig = useMemo( () => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig), [rawCanvasToolbarConfig] ); const effectiveAgentEnabled = agentEnabled && !isVideoWorkspace; const { data: session } = useSession(); const [input, setInput] = useState(""); const [attachments, setAttachments] = useState([]); const [selectedModelId, setSelectedModelId] = useState( effectiveAgentEnabled ? "auto" : "" ); 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 [detailUiValidation, setDetailUiValidation] = useState<{ plan: DetailUiValidationSnapshot | null; latestScreen: DetailUiReviewSnapshot | null; }>({ plan: null, latestScreen: null, }); const [detailSurfaceSnapshot, setDetailSurfaceSnapshot] = useState( null, ); 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 activeChatIdRef = useRef(chatId); const inFlightChatIdRef = useRef(null); const inFlightRawChatIdRef = useRef(null); const [chatSessions, setChatSessions] = useState([]); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false); const [selectedSkillTagId, setSelectedSkillTagId] = useState(null); const [messageViewportKey, setMessageViewportKey] = useState(0); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const agentSlotInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); const textareaRef = useRef(null); const detailModeSwitchRef = useRef(false); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(null); const contextPayloadRef = useRef(null); const handledCanvasMediaItemIdsRef = useRef>(new Set()); const lastWorkflowAutofillSignatureRef = useRef(null); const lastWorkflowAutofillUrlsRef = useRef([]); const pendingLaunchRef = useRef(null); const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0); const hasInitializedChatStateRef = useRef(false); const hasInitializedVideoWorkspaceRef = useRef(false); const sessionMenuRef = useRef(null); const historyScrollTimerRef = useRef | null>(null); const forceHistoryHydrationRef = useRef(null); // 🆕 标签悬停预览状态 const [tagPreview, setTagPreview] = useState(null); const previewTimeoutRef = useRef(null); const [fullscreenPreview, setFullscreenPreview] = useState<{ url: string; alt?: string; } | null>(null); const fullscreenPreviewSrc = useMemo(() => { if (!fullscreenPreview?.url) return ""; const originalUrl = inferOriginalImageUrl(fullscreenPreview.url); return buildImageProxyUrl(originalUrl || fullscreenPreview.url, { delivery: "proxy", width: 2400, quality: 90, format: "webp", }); }, [fullscreenPreview]); // 🆕 图片参数状态 const [imageParams, setImageParams] = useState({ aspectRatio: "auto", imageSize: "2K", imageCount: 1, }); const [videoParams, setVideoParams] = useState(null); const [videoSlotUploading, setVideoSlotUploading] = useState(null); const [agentSlotUploading, setAgentSlotUploading] = 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 [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] = useState>({}); const canvasGenerationPlaceholderByChatIdRef = useRef>({}); const streamImageGenerationState = useMemo( () => streamImageGenerationStateByChatId[activeChatId] || null, [activeChatId, streamImageGenerationStateByChatId] ); const [agentTargetLockStateByChatId, setAgentTargetLockStateByChatId] = useState>({}); const activeAgentTargetLock = useMemo( () => agentTargetLockStateByChatId[activeChatId] || null, [activeChatId, agentTargetLockStateByChatId] ); const [agentProgressStateByChatId, setAgentProgressStateByChatId] = useState>({}); const activeAgentProgress = useMemo( () => agentProgressStateByChatId[activeChatId] || null, [activeChatId, agentProgressStateByChatId] ); const imageParamsRef = useRef(imageParams); const videoParamsRef = useRef(videoParams); const deepThinkingEnabledRef = useRef(deepThinkingEnabled); const imageAutoTitleCacheRef = useRef>(new Map()); const sendGuardRef = useRef>({}); const storyboardNodeMapRef = useRef>>({}); const getSendGuard = useCallback((chatSessionId: string) => { return ( sendGuardRef.current[chatSessionId] || { inFlight: false, signature: "", timestamp: 0, } ); }, []); const setSendGuard = useCallback( ( chatSessionId: string, next: { inFlight: boolean; signature: string; timestamp: number; } ) => { sendGuardRef.current[chatSessionId] = next; }, [] ); const releaseSendGuard = useCallback( (chatSessionId: string) => { sendGuardRef.current[chatSessionId] = { ...getSendGuard(chatSessionId), inFlight: false, }; }, [getSendGuard] ); const setStreamImageGenerationStateForChat = useCallback( (chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => { setStreamImageGenerationStateByChatId((prev) => ({ ...prev, [chatSessionId]: next, })); }, [] ); const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => { setStreamImageGenerationStateByChatId((prev) => { if (!(chatSessionId in prev)) return prev; const next = { ...prev }; delete next[chatSessionId]; return next; }); }, []); const setAgentProgressStateForChat = useCallback( (chatSessionId: string, next: AgentProgressStreamPayload | null) => { setAgentProgressStateByChatId((prev) => ({ ...prev, [chatSessionId]: next, })); }, [] ); const clearAgentProgressStateForChat = useCallback((chatSessionId: string) => { setAgentProgressStateByChatId((prev) => { if (!(chatSessionId in prev)) return prev; const next = { ...prev }; delete next[chatSessionId]; return next; }); }, []); const shouldBlockDuplicateSend = useCallback((chatSessionId: string, signature: string) => { const now = Date.now(); const previous = getSendGuard(chatSessionId); return ( previous.signature === signature && now - previous.timestamp < 4500 ); }, [getSendGuard]); const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => { setActiveTypingSections((prev) => { if (active) { if (prev[typingKey]) return prev; return { ...prev, [typingKey]: true }; } if (!prev[typingKey]) return prev; const next = { ...prev }; delete next[typingKey]; return next; }); }, []); const handleTypewriterComplete = useCallback((typingKey: string) => { setTypedAssistantSections((prev) => prev[typingKey] ? prev : { ...prev, [typingKey]: true } ); }, []); const autoNameGeneratedImageNode = useCallback( async (nodeId: string, imageUrl: string) => { if (!nodeId || !imageUrl) return; const normalizeAutoTitle = (rawTitle: string) => { const compact = String(rawTitle || "").replace(/\s+/g, "").trim(); if (!compact || compact === "新画布" || compact === "未命名画布" || compact.includes("画布")) { return "图片"; } return compact.slice(0, 12); }; const applyNodeTitle = (title: string) => { const storeState = useCanvasStore.getState(); const currentNode = storeState.getNode(nodeId); if (!currentNode) return; const currentData = ((currentNode.data as any) || {}) as Record; 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(() => { activeChatIdRef.current = activeChatId; setTypedAssistantSections({}); setActiveTypingSections({}); setSelectedSkillTagId(null); }, [activeChatId]); useEffect(() => { const now = Date.now(); let rootChatId = chatId; if (chatScope === "agent") { try { const rootKey = getSessionRootStorageKey(canvasId, chatScope); const savedRootChatId = localStorage.getItem(rootKey); if (savedRootChatId && savedRootChatId.startsWith("canvas-")) { rootChatId = savedRootChatId; } else { rootChatId = `canvas-${crypto.randomUUID()}`; localStorage.setItem(rootKey, rootChatId); } } catch (error) { console.error("[CanvasChat] Failed to initialize agent root chat id:", error); rootChatId = `canvas-${crypto.randomUUID()}`; } } const fallbackSession: CanvasChatSession = { id: rootChatId, title: "对话 1", createdAt: now, updatedAt: now, }; try { const key = getSessionStorageKey(canvasId, chatScope); const saved = localStorage.getItem(key); const parsed = saved ? (JSON.parse(saved) as CanvasChatSession[]) : []; const sanitized = parsed .filter((session) => session?.id) .map((session) => ({ id: session.id, title: (session.title || "").trim() || "新对话", createdAt: Number(session.createdAt) || now, updatedAt: Number(session.updatedAt) || now, })); const hasCurrent = sanitized.some((session) => session.id === rootChatId); const nextSessions = hasCurrent ? sanitized : [fallbackSession, ...sanitized]; const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt); setChatSessions(sorted); setActiveChatId(rootChatId); localStorage.setItem(key, JSON.stringify(sorted)); } catch (error) { console.error("[CanvasChat] Failed to load chat sessions:", error); setChatSessions([fallbackSession]); setActiveChatId(rootChatId); } }, [canvasId, chatId, chatScope]); useEffect(() => { if (!isSessionMenuOpen) return; const handleClickOutside = (event: MouseEvent) => { if (!sessionMenuRef.current?.contains(event.target as Node)) { setIsSessionMenuOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isSessionMenuOpen]); useEffect(() => { try { localStorage.setItem( getSessionStorageKey(canvasId, chatScope), JSON.stringify(chatSessions) ); } catch (error) { console.error("[CanvasChat] Failed to save chat sessions:", error); } }, [canvasId, chatScope, chatSessions]); // Canvas store const addNode = useCanvasStore((state) => state.addNode); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const nodes = useCanvasStore((state) => state.nodes); const projectTitle = useCanvasStore((state) => state.projectTitle); const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds); const viewport = useCanvasStore((state) => state.viewport); const canvasRevision = useCanvasStore((state) => state.canvasRevision); const canvasUpdatedAt = useCanvasStore((state) => state.canvasUpdatedAt); const selectNodes = useCanvasStore((state) => state.selectNodes); const setTransientViewport = useCanvasStore((state) => state.setTransientViewport); 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); const clearLatestGeneratedImageHighlights = useCallback(() => { const currentNodes = useCanvasStore.getState().nodes; const clearedAt = Date.now(); currentNodes.forEach((node) => { if (node.type !== "image") return; const data = node.data as Record; const metadata = (data?.metadata || {}) as Record; if (metadata.latestGeneratedHighlight !== true) return; updateNode(node.id, { data: { ...data, metadata: { ...metadata, latestGeneratedHighlight: false, latestGeneratedClearedAt: clearedAt, }, } as any, }); }); }, [updateNode]); // 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用 const rawChatId = toRawCanvasChatId(activeChatId); 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 originalUrl = inferOriginalImageUrl(raw) || raw; const filename = buildDownloadFilename(originalUrl); try { const response = await fetch( buildImageProxyUrl(originalUrl, { delivery: "proxy" }) ); if (!response.ok) { throw new Error(`download failed: ${response.status}`); } const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); triggerBrowserDownload(blobUrl, filename); window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000); return; } catch (error) { console.error("下载原图失败:", error); try { triggerBrowserDownload(originalUrl, filename); toast.message("代理下载失败,已切换为原图直链下载"); return; } catch (fallbackError) { console.error("原图直链下载失败:", fallbackError); } triggerBrowserDownload(originalUrl, undefined, { openInNewTab: true }); toast.message("已打开原图,可右键另存为"); } }, []); // 从 API 加载历史消息 const { data: historyData, mutate: mutateHistoryData } = useSWR<{ messages: ChatMessage[]; }>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 10000, }); const initialCanvasMessageCount = activeChatId === chatId ? initialMessages.length : 0; const voteSWRKey = rawChatId && (historyData?.messages?.length ?? initialCanvasMessageCount) >= 2 ? `/api/vote?chatId=${rawChatId}` : null; const { data: messageVotes } = useSWR( voteSWRKey, fetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 60000, } ); // 获取选中的图片节点 const selectedImageNodes = useMemo( () => nodes.filter( (node) => selectedNodeIds.includes(node.id) && isCanvasImageReadyForAi(node) ), [nodes, selectedNodeIds] ); const selectedImageNodesRef = useRef([]); const generationAnchorNodesRef = useRef([]); useEffect(() => { selectedImageNodesRef.current = selectedImageNodes; }, [selectedImageNodes]); const focusCanvasOnNode = useCallback( (node: Pick) => { if (typeof window === "undefined") return; const { viewport } = useCanvasStore.getState(); const width = Number(node.size?.width || 300); const height = Number(node.size?.height || 300); const availableWidth = Math.max(360, window.innerWidth * 0.54); const availableHeight = Math.max(320, window.innerHeight * 0.52); const fitZoom = Math.min( 1.16, availableWidth / Math.max(width, 1), availableHeight / Math.max(height, 1) ); const zoom = Math.max(0.48, Math.min(fitZoom, 4)); const centerX = node.position.x + width / 2; const centerY = node.position.y + height / 2; window.requestAnimationFrame(() => { setTransientViewport({ x: window.innerWidth * 0.52 - centerX * zoom, y: window.innerHeight * 0.46 - centerY * zoom, zoom, }); }); }, [setTransientViewport] ); const focusCanvasOnNodes = useCallback( ( targetNodes: Array>, padding = 120 ) => { if (typeof window === "undefined" || targetNodes.length === 0) return; if (targetNodes.length === 1) { focusCanvasOnNode(targetNodes[0]); return; } const minX = Math.min(...targetNodes.map((node) => Number(node.position.x || 0))); const minY = Math.min(...targetNodes.map((node) => Number(node.position.y || 0))); const maxX = Math.max( ...targetNodes.map( (node) => Number(node.position.x || 0) + Number(node.size?.width || 300) ) ); const maxY = Math.max( ...targetNodes.map( (node) => Number(node.position.y || 0) + Number(node.size?.height || 300) ) ); const contentWidth = Math.max(1, maxX - minX + padding * 2); const contentHeight = Math.max(1, maxY - minY + padding * 2); const availableWidth = Math.max(360, window.innerWidth * 0.54); const availableHeight = Math.max(320, window.innerHeight * 0.58); const zoom = Math.max( 0.08, Math.min(1.4, availableWidth / contentWidth, availableHeight / contentHeight, 4) ); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; window.requestAnimationFrame(() => { setTransientViewport({ x: window.innerWidth * 0.52 - centerX * zoom, y: window.innerHeight * 0.46 - centerY * zoom, zoom, }); }); }, [focusCanvasOnNode, setTransientViewport] ); const applyCanvasViewOperation = useCallback( (payload: CanvasViewOperationStreamPayload) => { if (!payload || typeof payload !== "object") return; const targetProjectId = String(payload.projectId || "").trim(); if (targetProjectId && targetProjectId !== canvasId) return; const store = useCanvasStore.getState(); const currentNodes = store.nodes; const selectedIds = store.selectedNodeIds.filter(Boolean); const zoomByFactor = (factor: number) => { if (typeof window === "undefined") return; const currentViewport = useCanvasStore.getState().viewport; const nextZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * factor)); const anchorX = window.innerWidth * 0.52; const anchorY = window.innerHeight * 0.46; const scaleChange = nextZoom / currentViewport.zoom; setTransientViewport({ x: anchorX - (anchorX - currentViewport.x) * scaleChange, y: anchorY - (anchorY - currentViewport.y) * scaleChange, zoom: nextZoom, }); }; if (payload.operation === "zoom_in") { zoomByFactor(1.22); return; } if (payload.operation === "zoom_out") { zoomByFactor(0.82); return; } if (payload.operation === "zoom_to" && Number.isFinite(Number(payload.zoom))) { const currentViewport = useCanvasStore.getState().viewport; setTransientViewport({ ...currentViewport, zoom: Math.max(0.1, Math.min(4, Number(payload.zoom))), }); return; } if ( payload.operation === "pan_to" && payload.center && typeof payload.center === "object" && typeof window !== "undefined" ) { setTransientViewport( computeCanvasAgentPanViewport({ currentViewport: useCanvasStore.getState().viewport, center: payload.center, stage: { width: window.innerWidth, height: window.innerHeight, }, zoom: payload.zoom, }) ); return; } const targetIds = resolveCanvasViewOperationTargetNodeIds({ plan: payload, nodes: currentNodes, selectedNodeIds: selectedIds, }); const targetNodes = payload.operation === "fit_all" ? currentNodes.filter((node) => ["image", "video", "agent-slot", "ui-screen", "text"].includes(String(node.type)) ) : currentNodes.filter((node) => targetIds.includes(node.id)); if (targetNodes.length === 0) return; if (targetIds.length > 0) { store.selectNodes(targetIds); } focusCanvasOnNodes(targetNodes, Number(payload.padding || 120)); }, [canvasId, focusCanvasOnNodes, setTransientViewport] ); 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 => { if (isCanvasImageUploadPending(node)) return null; const nodeData = (node?.data as any) || {}; const src = nodeData.src; if (!src || typeof src !== "string" || !isValidChatHttpUrl(src)) return null; const nodeTitle = String(nodeData.title || "").trim() || String(nodeData.prompt || "").trim() || fallbackName; const metadata = (nodeData.metadata || {}) as Record; const width = Number(metadata.originalWidth || 0) || Number(node?.size?.width || 0) || 0; const height = Number(metadata.originalHeight || 0) || Number(node?.size?.height || 0) || 0; 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, width: Number.isFinite(width) && width > 0 ? Math.round(width) : undefined, height: Number.isFinite(height) && height > 0 ? Math.round(height) : undefined, }; }, [] ); // 🆕 辅助函数:在 contenteditable 中插入图片标签 const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => { const editableDiv = textareaRef.current as HTMLDivElement | null; if (!editableDiv || !editableDiv.isContentEditable) return; if (!isValidChatHttpUrl(url)) { console.warn('[CanvasChat] 跳过非 HTTP 图片标签:', String(url || '').slice(0, 40)); return; } // 检查 DOM 中是否已存在该 URL 的标签(如果存在则不重复添加) const existing = Array.from( editableDiv.querySelectorAll('[data-attachment-url]') ).find((element) => element.getAttribute('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 dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 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; const image = document.createElement('img'); image.src = buildPreviewImageUrl(url, 48); image.alt = ''; image.className = 'h-5 w-5 object-cover rounded-full border border-border/50'; const label = document.createElement('span'); label.className = 'text-xs text-foreground/80'; label.textContent = shortName; tagSpan.appendChild(image); tagSpan.appendChild(label); // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 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 dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 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 ); const pickedNode = nodes.find((node) => node.id === picked.imageNodeId); const pickedNodeMetadata = ((pickedNode?.data as any)?.metadata || {}) as Record; const pickedWidth = Number(pickedNodeMetadata.originalWidth || 0) || Number(pickedNode?.size?.width || 0) || 0; const pickedHeight = Number(pickedNodeMetadata.originalHeight || 0) || Number(pickedNode?.size?.height || 0) || 0; // 添加到附件列表(带物品信息,原图片 URL 会发送给 AI 用于多图融合编辑) currentAttachments.push({ url: picked.imageUrl, // 原图片 URL name: `${picked.objectName}-${picked.objectPosition}`, // 物品名称-位置 contentType: 'image/jpeg', width: Number.isFinite(pickedWidth) && pickedWidth > 0 ? Math.round(pickedWidth) : undefined, height: Number.isFinite(pickedHeight) && pickedHeight > 0 ? Math.round(pickedHeight) : undefined, 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, nodes]); // 🆕 标签悬停预览 - 使用全局事件监听 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; availabilityStatus?: "available" | "maintenance" | "testing"; availabilityLabel?: string | null; selectable?: boolean; disabledReason?: string | null; 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; creditCostMultiplierBySize?: Record; creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; creditCostPerVideoByResolution?: Record; } | 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; creditCostMultiplierBySize?: Record; creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; creditCostPerVideoByResolution?: Record; } | null; } | null; }>("/api/models?includeUnavailable=1", 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; canvasToolbarConfig?: CanvasToolbarConfig; }>("/api/site-settings?scope=canvas-chat", fetcher); const { data: canvasSkillTagsData } = useSWR<{ enabled?: boolean; title?: string; tags?: CanvasSkillTag[]; }>( "/api/canvas-skill-tags", fetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 60000, } ); 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 canvasToolbarConfig = useMemo( () => rawCanvasToolbarConfig ? propCanvasToolbarConfig : normalizeCanvasToolbarConfig(siteSettings?.canvasToolbarConfig), [propCanvasToolbarConfig, rawCanvasToolbarConfig, siteSettings?.canvasToolbarConfig] ); const getToolbarItem = useCallback( (key: CanvasToolbarItemKey) => getCanvasToolbarItem(canvasToolbarConfig, key), [canvasToolbarConfig] ); 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(); }, { revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 60000, } ); const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset }>( selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher ); const greetingUserName = useMemo(() => { const userName = String(session?.user?.name || "").trim(); if (userName) return userName; const email = String(session?.user?.email || "").trim(); if (email.includes("@")) { return email.split("@")[0]; } return ""; }, [session?.user?.email, session?.user?.name]); const [canvasTypedPreviewText, setCanvasTypedPreviewText] = useState(""); const [canvasPreviewCursorVisible, setCanvasPreviewCursorVisible] = useState(true); const [greetingPeriodText, setGreetingPeriodText] = useState("中午好!"); const [isCanvasBannerDismissed, setIsCanvasBannerDismissed] = useState(false); 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 canUseDetailGeneration = aiDetailGenerationEnabled && !isVideoWorkspace; const shouldShowCanvasBanner = !isDetailGenerationMode && !isCanvasBannerDismissed && (siteSettings?.bannerEnabled ?? true) && Boolean(siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"); useEffect(() => { setIsCanvasBannerDismissed(false); }, [siteSettings?.bannerEnabled, siteSettings?.bannerText]); const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false; const canvasSkillTagsTitle = String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE; const canvasSkillTags = useMemo( () => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []), [canvasSkillTagsData?.tags, canvasSkillTagsEnabled] ); const selectedSkillTag = useMemo( () => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null, [canvasSkillTags, selectedSkillTagId] ); const selectedAgentPreset = useMemo( () => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null), [agentPresetResponse] ); const workflowReferenceImages = workflowContext?.referenceImages || []; const hasWorkflowContext = Boolean( workflowContext && (String(workflowContext.summaryText || "").trim() || String(workflowContext.promptText || "").trim() || workflowContext.selections.length > 0 || workflowReferenceImages.length > 0) ); const SelectedSkillTagIcon = useMemo( () => getCanvasSkillIconComponent(selectedSkillTag?.iconName), [selectedSkillTag?.iconName] ); useEffect(() => { if (!selectedSkillTagId) return; if (!canvasSkillTags.some((tag) => tag.id === selectedSkillTagId)) { setSelectedSkillTagId(null); } }, [canvasSkillTags, selectedSkillTagId]); useEffect(() => { if (!isWorkspacePresentation) return; if (!selectedSkillTagId) return; setSelectedSkillTagId(null); }, [isWorkspacePresentation, selectedSkillTagId]); const visualModels = useMemo(() => { const filtered = modelsData?.models?.filter( (model) => model.modelId !== "volcano-video-master" && ( isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) || model.isMultimodal || isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ) ) || []; 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, capabilities: null, isMultimodal: false, selectable: true, availabilityStatus: "available" as const, availabilityLabel: null, disabledReason: null, 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, capabilities: null, isMultimodal: true, selectable: true, availabilityStatus: "available" as const, availabilityLabel: null, disabledReason: null, videoToolCapability: null, imageGenConfig: modelsData.agentModel.imageGenConfig || undefined, }; }, [modelsData?.agentModel]); // 所有可选模型(Agent模式 + 可视化模型) const allModels = useMemo(() => { const merged = [ ...(effectiveAgentEnabled ? [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, effectiveAgentEnabled, multimodalAgentModel, visualModels]); const imageOnlyModels = useMemo( () => visualModels.filter( (model) => isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) && !model.isMultimodal && !isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ), [visualModels] ); const conversationOnlyModels = useMemo( () => allModels.filter( (model) => model.id !== "auto" && model.id !== modelsData?.agentModel?.id && model.isMultimodal && !isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) && !isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ), [allModels, modelsData?.agentModel?.id] ); const videoOnlyModels = useMemo( () => visualModels.filter((model) => isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ), [visualModels] ); const currentModel = allModels.find((model) => model.id === selectedModelId); const isCanvasVideoModel = useCallback( (model?: (typeof allModels)[number] | null) => Boolean( model && isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ), [] ); const isCanvasImageModel = useCallback( (model?: (typeof allModels)[number] | null) => Boolean( model && isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ), [] ); const isCanvasConversationOnlyModel = useCallback( (model?: (typeof allModels)[number] | null) => Boolean( model && model.id !== "auto" && model.isMultimodal && !isCanvasImageModel(model) && !isCanvasVideoModel(model) ), [isCanvasImageModel, isCanvasVideoModel] ); const isVideoModel = Boolean( currentModel && isCanvasVideoModel(currentModel) ); const isConversationModel = Boolean( currentModel && isCanvasConversationOnlyModel(currentModel) ); const hasSelectedAgentPreset = Boolean(String(selectedAgentPresetId || "").trim()); const [agentEntryMode, setAgentEntryMode] = useState<"auto" | "preset">( hasSelectedAgentPreset ? "preset" : "auto" ); const isPresetAgentSelection = hasSelectedAgentPreset && agentEntryMode === "preset"; const isAgentModel = effectiveAgentEnabled && hasSelectedAgentPreset && !isDetailGenerationMode && (!selectedModelId || selectedModelId === "auto" || isConversationModel); const computedComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => { if (isDetailGenerationMode) return "detail"; if (isVideoModel) return "video"; if (isAgentModel) return "agent"; return "image"; }, [isAgentModel, isDetailGenerationMode, isVideoModel]); const [composerModeOverride, setComposerModeOverride] = useState< "agent" | "image" | "video" | "detail" | null >(null); const activeComposerMode = composerModeOverride || computedComposerMode; const agentSelectableModels = useMemo( () => effectiveAgentEnabled ? [SMART_AUTO_MODEL, ...conversationOnlyModels] : conversationOnlyModels, [SMART_AUTO_MODEL, effectiveAgentEnabled, conversationOnlyModels] ); const selectableModels = useMemo(() => { if (activeComposerMode === "detail") { return [SMART_AUTO_MODEL]; } if (activeComposerMode === "video") { return videoOnlyModels; } if (activeComposerMode === "agent") { return agentSelectableModels; } return imageOnlyModels; }, [ SMART_AUTO_MODEL, activeComposerMode, agentSelectableModels, imageOnlyModels, videoOnlyModels, ]); const enabledSelectableModels = useMemo( () => selectableModels.filter((model) => model.selectable !== false), [selectableModels] ); const modelSelectValue = enabledSelectableModels.some((model) => model.id === selectedModelId) ? selectedModelId : enabledSelectableModels[0]?.id || selectedModelId; const selectDisplayModel = selectableModels.find((model) => model.id === modelSelectValue) || currentModel || SMART_AUTO_MODEL; const agentDisplayName = activeComposerMode === "agent" ? isPresetAgentSelection ? selectedAgentPreset?.name || "预设Agent" : "Auto Agent" : currentModel?.displayName || selectDisplayModel?.displayName || "Agent模式"; const videoFrameSlots = useMemo(() => { if (!isVideoModel || !videoParams) return [] as string[]; if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[]; if (videoParams.mode === "first-frame") return ["首帧"] as string[]; return [] as string[]; }, [isVideoModel, videoParams]); const preferredVideoWorkspaceModel = useMemo(() => { if (isVideoModel && currentModel) { return currentModel; } const lastPreferredModel = allModels.find((model) => model.id === lastVideoModelId); if (lastPreferredModel && isCanvasVideoModel(lastPreferredModel)) { return lastPreferredModel; } return videoOnlyModels[0] || null; }, [ allModels, currentModel, isCanvasVideoModel, isVideoModel, lastVideoModelId, videoOnlyModels, ]); const videoWorkspaceQuickActions = useMemo(() => { const supportedModes = new Set( preferredVideoWorkspaceModel?.videoToolCapability?.modeOptions?.map((item) => item.mode) || [ "text-to-video", "first-frame", "first-last-frame", "reference-images", ] ); return [ { mode: "text-to-video" as const, title: "文生视频", description: "直接描述主体、镜头和运动", icon: Clapperboard, disabled: !supportedModes.has("text-to-video"), }, { mode: "first-frame" as const, title: "主体参考", description: "用首帧锁定主体与画风", icon: ImagePlus, disabled: !supportedModes.has("first-frame"), }, { mode: "first-last-frame" as const, title: "首尾帧", description: "同时控制开场和收尾画面", icon: ArrowRightToLine, disabled: !supportedModes.has("first-last-frame"), }, { mode: "reference-images" as const, title: "智能多帧", description: "多图共同约束场景一致性", icon: LayoutGrid, disabled: !supportedModes.has("reference-images"), }, ]; }, [preferredVideoWorkspaceModel]); const activeComposerModeRef = useRef(activeComposerMode); const agentEntryModeRef = useRef(agentEntryMode); const agentUploadSlots = useMemo(() => { if ( activeComposerMode !== "agent" || !isPresetAgentSelection || !selectedAgentPreset || selectedAgentPreset.type === "text" ) { return [] as Array<{ slotIndex: number; label: string; }>; } return [ { slotIndex: 0, label: "产品图1" }, { slotIndex: 1, label: "产品图2" }, { slotIndex: 2, label: "模特图" }, ]; }, [activeComposerMode, isPresetAgentSelection, selectedAgentPreset]); const hasAgentUploadSlots = activeComposerMode === "agent" && agentUploadSlots.length > 0; const agentRequiredUploadCount = hasAgentUploadSlots ? 3 : 0; // 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签 useEffect(() => { if (pendingChatAttachments.length === 0) return; const safePendingChatAttachments = pendingChatAttachments .map((item) => sanitizeChatAttachment(item as PickedObjectAttachment)) .filter((item): item is PickedObjectAttachment => Boolean(item)); if (safePendingChatAttachments.length === 0) { toast.error("图片还未完成上传,请重新添加到对话"); clearPendingChatAttachments(); return; } if (safePendingChatAttachments.length < pendingChatAttachments.length) { toast.warning("部分图片地址不可用,已跳过以避免页面卡顿"); } if (isDetailGenerationMode) { const normalized = safePendingChatAttachments.map((item) => ({ ...item, name: item.name || "素材图", })); if (normalized.length > 0) { let inserted = 0; const targetSlotIndex = detailSlotPickerIndex; setDetailSlots((current) => { const next = [...current]; const allIndexes = DETAIL_SLOT_CONFIGS.map((_, index) => index); let priorityIndexes = allIndexes; if (targetSlotIndex !== null && targetSlotIndex >= 0 && targetSlotIndex < DETAIL_SLOT_CONFIGS.length) { const role = DETAIL_SLOT_CONFIGS[targetSlotIndex]?.role; const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) => slot.role === role ? index : -1 ).filter((index) => index >= 0); const sameRoleStart = sameRoleIndexes.indexOf(targetSlotIndex); const orderedSameRole = sameRoleStart >= 0 ? sameRoleIndexes.slice(sameRoleStart) : sameRoleIndexes; priorityIndexes = [targetSlotIndex, ...orderedSameRole.filter((index) => index !== targetSlotIndex)]; } let cursor = 0; for (let i = 0; i < priorityIndexes.length && cursor < normalized.length; i += 1) { const slotIdx = priorityIndexes[i]!; const canReplaceActive = targetSlotIndex !== null && slotIdx === targetSlotIndex && cursor === 0; if (!canReplaceActive && next[slotIdx]) continue; next[slotIdx] = normalized[cursor]!; cursor += 1; inserted += 1; } return next; }); if (inserted > 0) { toast.success( inserted > 1 ? `已添加 ${inserted} 张到 AI详情素材槽位` : "已添加到 AI详情素材槽位" ); if (targetSlotIndex !== null) { setDetailSlotPickerIndex(null); } } else { toast.info("详情素材槽位已满,请先替换或清空后再添加"); } } clearPendingChatAttachments(); return; } if (hasAgentUploadSlots) { const slotCount = agentUploadSlots.length; const currentSlots: Array = Array.from( { length: slotCount }, (_, index) => attachments[index] ?? null ); const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url)); const toAdd = safePendingChatAttachments .filter((item) => !existingUrls.has(item.url)) .map((item) => ({ ...item, name: item.name || "参考图", })) as PickedObjectAttachment[]; let cursor = 0; let inserted = 0; for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) { if (currentSlots[index]) continue; currentSlots[index] = toAdd[cursor]!; cursor += 1; inserted += 1; } if (inserted > 0) { const next = currentSlots .slice(0, slotCount) .filter((item): item is PickedObjectAttachment => Boolean(item)); setAttachments(next); toast.success( slotCount === 1 ? "已添加到风格化图片槽位" : `已添加到风格化槽位(${inserted}/${slotCount})` ); } else if (toAdd.length > 0) { toast.info("风格化图片槽位已满,请先替换或移除"); } if (toAdd.length - inserted > 0) { toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换风格化图片槽位`); } setTimeout(() => { (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); clearPendingChatAttachments(); return; } if (isVideoModel && videoFrameSlots.length > 0) { const slotCount = videoFrameSlots.length; const currentSlots: Array = Array.from( { length: slotCount }, (_, index) => attachments[index] ?? null ); const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url)); const toAdd = safePendingChatAttachments .filter((item) => !existingUrls.has(item.url)) .map((item) => ({ ...item, name: item.name || "参考图", })) 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 = safePendingChatAttachments.filter((item) => !existingUrls.has(item.url)); if (toAdd.length > 0) { toAdd.forEach((item, index) => { insertImageTagInEditor(item.url, item.name, currentAttachments.length + index); }); setAttachments([...currentAttachments, ...toAdd]); setTimeout(() => { (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); } clearPendingChatAttachments(); }, [ pendingChatAttachments, isDetailGenerationMode, detailSlotPickerIndex, hasAgentUploadSlots, agentUploadSlots.length, isVideoModel, videoFrameSlots.length, attachments, insertImageTagInEditor, clearPendingChatAttachments, ]); const firstImageModelId = useMemo( () => imageOnlyModels.find((model) => model.selectable !== false)?.id || null, [imageOnlyModels] ); const firstVideoModelId = useMemo( () => videoOnlyModels.find((model) => model.selectable !== false)?.id || null, [videoOnlyModels] ); const activeModeLabel = activeComposerMode === "video" ? "视频" : activeComposerMode === "detail" ? getToolbarItem("chatModeDetailActive").label : activeComposerMode === "image" ? "图像" : isPresetAgentSelection ? getToolbarItem("chatModePresetAgent").label : "Agent"; const shouldMaskAgentRuntimeModelName = activeComposerMode === "agent" || selectedModelId === "auto"; const getAgentSafeModelName = useCallback( (modelName?: string | null) => { const normalized = String(modelName || "").trim(); if (!shouldMaskAgentRuntimeModelName) { return normalized || undefined; } return normalized ? "agent model" : "agent model"; }, [shouldMaskAgentRuntimeModelName] ); // 默认选择模型(根据系统设置) useEffect(() => { activeComposerModeRef.current = activeComposerMode; }, [activeComposerMode]); useEffect(() => { agentEntryModeRef.current = agentEntryMode; }, [agentEntryMode]); useEffect(() => { setAgentEntryMode(hasSelectedAgentPreset ? "preset" : "auto"); }, [hasSelectedAgentPreset, selectedAgentPresetId]); useEffect(() => { if (!selectedModelId && siteSettings) { const nextModelId = effectiveAgentEnabled ? "auto" : firstImageModelId || firstVideoModelId || ""; currentModelIdRef.current = nextModelId; setSelectedModelId(nextModelId); } }, [ effectiveAgentEnabled, firstImageModelId, firstVideoModelId, selectedModelId, siteSettings, ]); useEffect(() => { if (effectiveAgentEnabled) return; if (selectedModelId !== "auto" && activeComposerMode !== "agent") return; const fallbackModelId = firstImageModelId || firstVideoModelId || ""; currentModelIdRef.current = fallbackModelId; setSelectedModelId(fallbackModelId); setComposerModeOverride(firstVideoModelId && fallbackModelId === firstVideoModelId ? "video" : "image"); if (isDetailGenerationMode) { setIsDetailGenerationMode(false); } }, [ activeComposerMode, effectiveAgentEnabled, firstImageModelId, firstVideoModelId, isDetailGenerationMode, selectedModelId, ]); useEffect(() => { if (!canUseDetailGeneration && isDetailGenerationMode) { setIsDetailGenerationMode(false); } }, [canUseDetailGeneration, isDetailGenerationMode]); useEffect(() => { if (!modelSelectValue || modelSelectValue === selectedModelId) return; currentModelIdRef.current = modelSelectValue; setSelectedModelId(modelSelectValue); }, [modelSelectValue, selectedModelId]); useEffect(() => { if (!effectiveAgentEnabled) return; if (activeComposerMode !== "agent") return; if (!isPresetAgentSelection) return; if (!selectedAgentPresetId) return; if (!selectedAgentPreset?.defaultModelId) return; if (currentModelIdRef.current === selectedAgentPreset.defaultModelId) return; currentModelIdRef.current = selectedAgentPreset.defaultModelId; setSelectedModelId(selectedAgentPreset.defaultModelId); if (isDetailGenerationMode) { setIsDetailGenerationMode(false); } }, [ activeComposerMode, effectiveAgentEnabled, isPresetAgentSelection, isDetailGenerationMode, selectedAgentPreset, selectedAgentPresetId, ]); useEffect(() => { if (isDetailGenerationMode) { setAttachments([]); setDetailSections({ required: true, strategy: false, assets: true, archives: true }); const editableDiv = textareaRef.current as HTMLDivElement | null; if (editableDiv) { editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove()); } } else { setDetailProgress({ status: "idle", stage: "", current: 0, total: 0 }); } }, [isDetailGenerationMode]); const currentModelIdRef = useRef(selectedModelId); const isVideoModelRef = useRef(isVideoModel); useEffect(() => { currentModelIdRef.current = selectedModelId; }, [selectedModelId]); useEffect(() => { imageParamsRef.current = imageParams; }, [imageParams]); useEffect(() => { videoParamsRef.current = videoParams; }, [videoParams]); useEffect(() => { deepThinkingEnabledRef.current = deepThinkingEnabled; }, [deepThinkingEnabled]); useEffect(() => { isVideoModelRef.current = isVideoModel; }, [isVideoModel]); useEffect(() => { if (!isVideoModel) return; const normalized = normalizeVideoParamsForProvider( videoParams, currentModel?.providerType, currentModel?.videoToolCapability ); if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) { setVideoParams(normalized); } }, [isVideoModel, currentModel?.providerType, currentModel?.videoToolCapability, videoParams]); useEffect(() => { if (!isVideoModel || videoFrameSlots.length === 0) return; const maxAttachments = videoFrameSlots.length; setAttachments((current) => { if (current.length <= maxAttachments) return current; return current.slice(0, maxAttachments); }); }, [isVideoModel, videoFrameSlots.length]); useEffect(() => { if (!hasAgentUploadSlots) return; const maxAttachments = agentUploadSlots.length; setAttachments((current) => { if (current.length <= maxAttachments) return current; return current.slice(0, maxAttachments); }); }, [agentUploadSlots.length, hasAgentUploadSlots]); useEffect(() => { if (!hasAgentUploadSlots || workflowReferenceImages.length === 0) { lastWorkflowAutofillSignatureRef.current = null; lastWorkflowAutofillUrlsRef.current = []; return; } const primaryImages = workflowReferenceImages.filter((image) => image.source === "primary"); const secondaryImages = workflowReferenceImages.filter((image) => image.source === "secondary"); const usedUrls = new Set(); const pickNextImage = ( candidates: typeof workflowReferenceImages ) => candidates.find((image) => { if (!image?.url || usedUrls.has(image.url)) return false; usedUrls.add(image.url); return true; }) || null; const productSlot1Image = pickNextImage(primaryImages) || pickNextImage(secondaryImages); const productSlot2Image = pickNextImage(secondaryImages) || pickNextImage(primaryImages) || pickNextImage(workflowReferenceImages); const suggestedSlots: Array = Array.from( { length: agentUploadSlots.length }, () => null ); const toAttachment = (image: (typeof workflowReferenceImages)[number] | null) => image ? { url: image.url, name: image.name || image.label || image.sourceLabel, contentType: image.mimeType || "image/png", assetId: image.assetId || undefined, } : null; if (agentUploadSlots.length > 0) { suggestedSlots[0] = toAttachment(productSlot1Image); } if (agentUploadSlots.length > 1) { suggestedSlots[1] = toAttachment(productSlot2Image); } const nextSuggestedUrls = suggestedSlots .filter((item): item is PickedObjectAttachment => Boolean(item)) .map((item) => item.url); if (nextSuggestedUrls.length === 0) return; const nextSignature = JSON.stringify({ presetId: selectedAgentPresetId || "", urls: nextSuggestedUrls, }); if (lastWorkflowAutofillSignatureRef.current === nextSignature) { return; } setAttachments((current) => { const previousAutoUrls = new Set(lastWorkflowAutofillUrlsRef.current); const next: Array = Array.from( { length: agentUploadSlots.length }, (_, index) => current[index] ?? null ); let changed = false; suggestedSlots.forEach((attachment, index) => { if (!attachment || index >= 2) return; const existing = next[index]; const canReplace = !existing?.url || previousAutoUrls.has(existing.url); if (!canReplace) return; if (existing?.url === attachment.url) return; next[index] = attachment; changed = true; }); if (!changed) { return current; } lastWorkflowAutofillSignatureRef.current = nextSignature; lastWorkflowAutofillUrlsRef.current = nextSuggestedUrls; return next.filter((item): item is PickedObjectAttachment => Boolean(item)); }); }, [ agentUploadSlots.length, hasAgentUploadSlots, selectedAgentPresetId, workflowReferenceImages, ]); 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]); useEffect(() => { if (!isVideoWorkspace) return; if (hasInitializedVideoWorkspaceRef.current) return; if (!preferredVideoWorkspaceModel) return; hasInitializedVideoWorkspaceRef.current = true; if (isDetailGenerationMode) { setIsDetailGenerationMode(false); } setComposerModeOverride("video"); if ( !selectedModelId || selectedModelId === "auto" || !isCanvasVideoModel(currentModel || null) ) { currentModelIdRef.current = preferredVideoWorkspaceModel.id; setSelectedModelId(preferredVideoWorkspaceModel.id); } }, [ currentModel, isCanvasVideoModel, isDetailGenerationMode, isVideoWorkspace, preferredVideoWorkspaceModel, selectedModelId, ]); const handleSelectGenerationMode = useCallback( (mode: "agent" | "image" | "video" | "detail") => { hiddenPromptRef.current = null; autoContextRef.current = null; activeComposerModeRef.current = mode; setComposerModeOverride(mode); if (mode === "agent") { if (!effectiveAgentEnabled) { return; } const preferredAgentModelId = allModels.find( (model) => model.id === lastConversationModelId && isCanvasConversationOnlyModel(model) )?.id || "auto"; currentModelIdRef.current = preferredAgentModelId; setSelectedModelId(preferredAgentModelId); setIsDetailGenerationMode(false); return; } if (mode === "detail") { if (!canUseDetailGeneration) { toast.error("AI详情生成功能未开启"); return; } const detailModelId = effectiveAgentEnabled ? "auto" : ""; currentModelIdRef.current = detailModelId; setSelectedModelId(detailModelId); setIsDetailGenerationMode(true); setIsDetailWorkbenchCollapsed(true); return; } if (mode === "image") { const preferredImageModelId = allModels.find( (model) => model.id === lastImageModelId && model.id !== "auto" && isCanvasImageModel(model) && !model.isMultimodal )?.id || firstImageModelId; if (!preferredImageModelId) { toast.error("当前没有可用的图像模型"); return; } currentModelIdRef.current = preferredImageModelId; setSelectedModelId(preferredImageModelId); setIsDetailGenerationMode(false); return; } const preferredVideoModelId = allModels.find((model) => model.id === lastVideoModelId && isCanvasVideoModel(model))?.id || firstVideoModelId; if (!preferredVideoModelId) { toast.error("当前没有可用的视频模型"); return; } currentModelIdRef.current = preferredVideoModelId; setSelectedModelId(preferredVideoModelId); setIsDetailGenerationMode(false); }, [ canUseDetailGeneration, effectiveAgentEnabled, allModels, computedComposerMode, firstImageModelId, firstVideoModelId, hasSelectedAgentPreset, lastConversationModelId, lastImageModelId, isCanvasConversationOnlyModel, isCanvasImageModel, isCanvasVideoModel, lastVideoModelId, ] ); useEffect(() => { if (!effectiveAgentEnabled || typeof window === "undefined") return; const handleAgentSlotFillRequest = (event: Event) => { const detail = (event as CustomEvent<{ nodeId?: string; label?: string }>).detail; const label = String(detail?.label || "这个槽位").trim(); const prompt = `在${label === "这个槽位" ? label : `「${label}」`}生成一张图`; handleSelectGenerationMode("agent"); setInput(prompt); window.setTimeout(() => { const editableDiv = textareaRef.current as HTMLDivElement | null; if (!editableDiv) return; editableDiv.innerHTML = ""; editableDiv.appendChild(document.createTextNode(prompt)); editableDiv.focus(); const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(editableDiv); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); }, 0); toast.message("已选中槽位,可补充生成要求后发送"); }; window.addEventListener( "canvas-agent-slot-fill-request", handleAgentSlotFillRequest ); return () => { window.removeEventListener( "canvas-agent-slot-fill-request", handleAgentSlotFillRequest ); }; }, [effectiveAgentEnabled, handleSelectGenerationMode]); const applyVideoWorkspacePreset = useCallback( (mode: VideoGenerationParams["mode"]) => { if (!preferredVideoWorkspaceModel) { toast.error("当前没有可用的视频模型"); return; } handleSelectGenerationMode("video"); const nextParams = normalizeVideoParamsForProvider( { ...(videoParamsRef.current || {}), mode, }, preferredVideoWorkspaceModel.providerType, preferredVideoWorkspaceModel.videoToolCapability ); handleVideoParamsChange(nextParams); setTimeout(() => { (textareaRef.current as HTMLDivElement | null)?.focus(); }, 0); }, [handleSelectGenerationMode, preferredVideoWorkspaceModel] ); 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; } setDetailArchives(sanitizeDetailArchiveList(result.data)); } 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 = useMemo( () => sortCanvasMessagesByCreatedAt( historyData?.messages ?? (activeChatId === chatId ? initialMessages : []) ), [activeChatId, chatId, historyData?.messages, initialMessages] ); const removeCanvasGenerationPlaceholders = useCallback( (chatSessionId?: string) => { const state = useCanvasStore.getState(); const ids = state.nodes .filter((node) => { if (node.type !== "image") return false; const metadata = ((node.data as any)?.metadata || {}) as Record; if (metadata.canvasGenerationPlaceholder !== true) return false; if (!chatSessionId) return true; return String(metadata.chatId || "") === chatSessionId; }) .map((node) => node.id); ids.forEach((nodeId) => deleteNode(nodeId)); if (chatSessionId) { delete canvasGenerationPlaceholderByChatIdRef.current[chatSessionId]; } else { canvasGenerationPlaceholderByChatIdRef.current = {}; } }, [deleteNode] ); 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 placeholderNodes = latestNodes.filter((node) => { if (node.type !== "image") return false; const metadata = ((node.data as any)?.metadata || {}) as Record; return ( metadata.canvasGenerationPlaceholder === true && (!metadata.chatId || String(metadata.chatId) === activeChatId) ); }); const suppressedMediaKeys = new Set( (useCanvasStore.getState().getCanvasState().suppressedMediaKeys || []) .map((item) => String(item || "").trim()) .filter(Boolean) ); 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() || "对话生成"; const agentPresetId = String(payload.agentPresetId || "").trim() || undefined; const agentPresetName = String(payload.agentPresetName || "").trim() || undefined; const agentCategoryId = String(payload.agentCategoryId || "").trim() || undefined; const agentCategoryName = String(payload.agentCategoryName || "").trim() || undefined; const agentExecutionSnapshotId = String(payload.agentExecutionSnapshotId || "").trim() || undefined; let addedImageCount = 0; let addedVideoCount = 0; const insertedNodeIds: string[] = []; const completedAt = Date.now(); 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 ( suppressedMediaKeys.has(`image:${messageImageKey}`) || suppressedMediaKeys.has(`image-url:${url}`) ) { continue; } if (messageImageKey && existingMessageImageKeys.has(messageImageKey)) { continue; } existingImageUrlKeys.add(sourceImageKey); if (messageImageKey) { existingMessageImageKeys.add(messageImageKey); } const dimensions = await getImageDimensions(url); 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 currentNodes = useCanvasStore.getState().nodes; const anchorNode = findCanvasImageAnchorNode(currentNodes, { selectedNodes: generationAnchorNodesRef.current.length > 0 ? generationAnchorNodesRef.current : selectedImageNodesRef.current, sourceImageUrls, }); const reservedPlaceholderNode = placeholderNodes[index]; const placeholderNode = reservedPlaceholderNode ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null : null; const size = { width: dimensions.width, height: dimensions.height }; const position = placeholderNode ? getCenteredReplacementPosition(placeholderNode, size) : getAnchoredGenerationPosition({ anchorNode, existingNodes: currentNodes, fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, index, size, viewport: useCanvasStore.getState().viewport, }); const data = { src: url, status: "completed" as const, title: "图片", prompt: generationPrompt, originalSrc: canUseCompareSource ? compareSourceUrl : undefined, metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, generationPrompt, sourceMessageId: messageId || undefined, sourceImageKey, compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, agentPresetId, agentPresetName, agentCategoryId, agentCategoryName, agentExecutionSnapshotId, sourceNodeId: sourceNodeId || anchorNode?.id || undefined, placementMode: "anchored-generation", recentlyGeneratedAt: completedAt, latestGeneratedHighlight: true, }, }; const nodeId = placeholderNode ? placeholderNode.id : addNode({ type: "image", position, size, data, connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], }); if (placeholderNode) { updateNode(nodeId, { position, size, data, connections: sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : [], }); } insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; } placeholderNodes.slice(imageUrls.length).forEach((node) => deleteNode(node.id)); if (insertedNodeIds.length > 0) { selectNodes(insertedNodeIds); const focusNode = useCanvasStore .getState() .nodes.find((node) => node.id === insertedNodeIds[0]); if (focusNode) { focusCanvasOnNode(focusNode); } } else if (videoUrls.length > 0) { removeCanvasGenerationPlaceholders(activeChatId); } for (let index = 0; index < videoUrls.length; index += 1) { const url = videoUrls[index]!; const key = normalizeMediaUrl(url); if (!key || existingVideoUrlKeys.has(key)) { continue; } if (suppressedMediaKeys.has(`video:${url}`)) { continue; } existingVideoUrlKeys.add(key); const currentNodes = useCanvasStore.getState().nodes; const existingNodes = currentNodes.length + addedImageCount + addedVideoCount; const x = 120 + (existingNodes % 2) * 420; const y = 120 + Math.floor(existingNodes / 2) * 260; addNode({ type: "video", position: { x, y }, size: { width: 380, height: 220 }, data: { src: url, status: "completed", prompt: generationPrompt, metadata: { agentPresetId, agentPresetName, agentCategoryId, agentCategoryName, agentExecutionSnapshotId, }, }, connections: [], }); addedVideoCount += 1; } }, [ activeChatId, addNode, autoNameGeneratedImageNode, deleteNode, focusCanvasOnNode, removeCanvasGenerationPlaceholders, selectNodes, updateNode, ] ); const refreshCanvasAfterAgentOperation = useCallback( async (params: { projectId: string; createdNodeIds?: string[] }) => { const targetProjectId = String(params.projectId || "").trim(); if (!targetProjectId || targetProjectId !== canvasId) { return false; } const createdNodeIds = Array.isArray(params.createdNodeIds) ? params.createdNodeIds .map((id) => String(id || "").trim()) .filter(Boolean) : []; try { const result = await canvasService.getProject(targetProjectId); if (!result.success || !result.data?.canvasData) { useCanvasStore.getState().markAgentOperationRefresh(); void mutate(`/api/canvas/${targetProjectId}`); return false; } const store = useCanvasStore.getState(); store.markAgentOperationRefresh(); store.loadCanvas(result.data.canvasData); store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); if (createdNodeIds.length > 0) { const refreshedStore = useCanvasStore.getState(); refreshedStore.selectNodes(createdNodeIds); const focusNode = refreshedStore.nodes.find((node) => node.id === createdNodeIds[0]); if (focusNode) { focusCanvasOnNode(focusNode); } } return true; } catch (error) { console.error("[CanvasChat] Failed to refresh canvas after agent operation:", error); useCanvasStore.getState().markAgentOperationRefresh(); void mutate(`/api/canvas/${targetProjectId}`); return false; } }, [canvasId, focusCanvasOnNode, mutate] ); const { messages, setMessages, sendMessage, status, } = 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; const capturedContextPayload = contextPayloadRef.current ?? buildContextPayload(); console.log("[CanvasChat] Preparing request:", { id: rawId, modelId: currentModelIdRef.current, composerMode: activeComposerModeRef.current, agentEntryMode: agentEntryModeRef.current, imageParams: imageParamsRef.current, contextPayload: capturedContextPayload, lastMessage: request.messages.at(-1), }); const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const effectiveAgentEntryMode = agentEntryModeRef.current; const hiddenPrompt = hiddenPromptRef.current || undefined; const autoContext = autoContextRef.current || undefined; const contextPayload = capturedContextPayload; const requestModel = allModels.find((model) => model.id === currentModelIdRef.current); const requestIsVideoModel = isCanvasVideoModel(requestModel); const requestIsConversationModel = isCanvasConversationOnlyModel(requestModel); hiddenPromptRef.current = null; autoContextRef.current = null; contextPayloadRef.current = null; return { body: { id: rawId, message: request.messages.at(-1), agentPresetId: effectiveComposerMode === "agent" && effectiveAgentEntryMode === "preset" && selectedAgentPreset?.id ? selectedAgentPreset.id : undefined, selectedChatModel: currentModelIdRef.current, composerMode: effectiveComposerMode, 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) => { const eventChatId = inFlightChatIdRef.current || activeChatId; const isEventForActiveChat = eventChatId === activeChatIdRef.current; 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") { setStreamImageGenerationStateForChat(eventChatId, { phase: "finalizing", providerType: payload.providerType, modelName: getAgentSafeModelName(payload.modelName), imageCount: Number(payload.imageCount || 0) || undefined, }); window.setTimeout(() => { clearStreamImageGenerationStateForChat(eventChatId); }, 30_000); return; } setStreamImageGenerationStateForChat(eventChatId, { phase: payload.phase || "processing", providerType: payload.providerType, modelName: getAgentSafeModelName(payload.modelName), imageCount: Number(payload.imageCount || 0) || undefined, queuePosition: Number.isFinite(Number(payload.queuePosition)) && Number(payload.queuePosition) >= 0 ? Number(payload.queuePosition) : undefined, sameModelRunningCount: Number.isFinite(Number(payload.sameModelRunningCount)) && Number(payload.sameModelRunningCount) >= 0 ? Number(payload.sameModelRunningCount) : undefined, concurrencyLimit: Number.isFinite(Number(payload.concurrencyLimit)) && Number(payload.concurrencyLimit) > 0 ? Number(payload.concurrencyLimit) : undefined, estimatedWaitMs: Number.isFinite(Number(payload.estimatedWaitMs)) && Number(payload.estimatedWaitMs) >= 0 ? Number(payload.estimatedWaitMs) : undefined, }); } catch (e) { console.error("[CanvasChat] Failed to parse data-imageGenerationStatus:", e); } return; } if (dataPart.type === "data-canvasOperation") { try { const payload: CanvasOperationStreamPayload = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; if (payload.status === "failed") { toast.warning( getCanvasAgentOperationErrorMessage({ code: payload.errorCode, fallbackMessage: payload.errorMessage || payload.message, }) ); return; } if (payload.status === "shadowed") { console.info("[CanvasChat] Canvas operation shadowed:", { operationId: payload.operationId, operation: payload.operation, mediaItemIds: payload.mediaItemIds, }); return; } const handledIds = getCanvasOperationHandledMediaItemIds(payload); handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id)); const targetProjectId = String(payload.projectId || "").trim(); if (targetProjectId) { void refreshCanvasAfterAgentOperation({ projectId: targetProjectId, createdNodeIds: payload.createdNodeIds, }); } if (payload.message) { toast.success(payload.message); } } catch (e) { console.error("[CanvasChat] Failed to parse data-canvasOperation:", e); } return; } if (dataPart.type === "data-canvasViewOperation") { try { const payload: CanvasViewOperationStreamPayload = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; applyCanvasViewOperation(payload); } catch (e) { console.error("[CanvasChat] Failed to parse data-canvasViewOperation:", 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; const handledIds = getCanvasMediaPayloadHandledItemIds(payload); handledIds.forEach((id) => handledCanvasMediaItemIdsRef.current.add(id)); if ( shouldSkipCanvasMediaPayloadForHandledItems({ payload, handledCanvasMediaItemIds: handledCanvasMediaItemIdsRef.current, }) ) { clearStreamImageGenerationStateForChat(eventChatId); return; } 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 (isEventForActiveChat && (imageUrls.length > 0 || videoUrls.length > 0)) { const allUrls = [...imageUrls, ...videoUrls]; const fallbackMessageId = String(payload.messageId || "").trim() || buildCanvasMediaFallbackMessageId(eventChatId, allUrls); const mediaMarkdown = buildCanvasMediaMarkdown({ imageUrls, videoUrls }); const mediaUrlKeys = new Set( allUrls.map((url) => normalizeMediaUrl(url)).filter(Boolean) ); setMessages((prevMessages) => { const existingIndex = prevMessages.findIndex( (message) => message.id === fallbackMessageId ); if (existingIndex >= 0) { const existingMessage = prevMessages[existingIndex] as any; const existingText = ((existingMessage.parts || []) as any[]) .filter((part) => part?.type === "text" && typeof part.text === "string") .map((part) => String(part.text)) .join("\n"); const existingUrlKeys = new Set( [ ...extractAssistantImageUrls(existingText), ...extractVideoUrls(existingText), ] .map((url) => normalizeMediaUrl(url)) .filter(Boolean) ); const hasAllMedia = Array.from(mediaUrlKeys).every((key) => existingUrlKeys.has(key) ); if (hasAllMedia) return prevMessages; const nextText = [existingText, mediaMarkdown] .filter((text) => String(text || "").trim()) .join("\n\n"); const nextMessages = [...prevMessages]; nextMessages[existingIndex] = { ...existingMessage, role: "assistant", content: nextText, parts: [{ type: "text", text: nextText }], }; return sortCanvasMessagesByCreatedAt(nextMessages); } const createdAt = new Date(); return sortCanvasMessagesByCreatedAt([ ...prevMessages, { id: fallbackMessageId, role: "assistant", content: mediaMarkdown, createdAt, parts: [{ type: "text", text: mediaMarkdown }], } as any, ]); }); } void appendStreamMediaToCanvas(payload).finally(() => { clearStreamImageGenerationStateForChat(eventChatId); }); } catch (e) { console.error("[CanvasChat] Failed to parse data-canvasMedia:", e); } return; } if (dataPart.type === "data-agentTargetLock") { try { const payload: AgentTargetLockStreamPayload = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; setAgentTargetLockStateByChatId((prev) => ({ ...prev, [eventChatId]: payload, })); } catch (e) { console.error("[CanvasChat] Failed to parse data-agentTargetLock:", e); } return; } if (dataPart.type === "data-agentProgress") { try { const payload: AgentProgressStreamPayload = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; setAgentProgressStateForChat(eventChatId, { stage: payload.stage || "planning", currentStep: payload.currentStep || "analyze", title: payload.title, message: payload.message, toolName: payload.toolName, capability: payload.capability, }); } catch (e) { console.error("[CanvasChat] Failed to parse data-agentProgress:", 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; const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; const normalizedAttachments = Array.isArray(messageData.attachments) ? messageData.attachments : []; const emptyNearestUserImageKeys = new Set(); const incomingImageUrls = normalizedParts.flatMap((part: any) => part?.type === "text" && typeof part?.text === "string" ? extractAssistantImageUrls(part.text, emptyNearestUserImageKeys) : typeof part?.url === "string" && /^https?:\/\//i.test(part.url) ? [part.url] : [] ); const incomingVideoUrls = normalizedParts.flatMap((part: any) => part?.type === "text" && typeof part?.text === "string" ? extractVideoUrls(part.text) : typeof part?.url === "string" && /^https?:\/\//i.test(part.url) && /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(part.url) ? [part.url] : [] ); normalizedAttachments.forEach((attachment: any) => { const rawUrl = String(attachment?.url || "").trim(); if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) return; const contentType = String(attachment?.contentType || "").toLowerCase(); if ( contentType.startsWith("video/") || /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(rawUrl) ) { incomingVideoUrls.push(rawUrl); return; } incomingImageUrls.push(rawUrl); }); if (hasImageGenStartMarker) { const previousState = streamImageGenerationStateByChatId[eventChatId] || null; setStreamImageGenerationStateForChat(eventChatId, { phase: "processing", providerType: previousState?.providerType || (activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType), modelName: previousState?.modelName || getAgentSafeModelName(currentModel?.displayName), imageCount: previousState?.imageCount || Math.max(1, Number(imageParamsRef.current?.imageCount || 1)), }); } const shouldDeferClearForActiveMedia = isEventForActiveChat && (incomingImageUrls.length > 0 || incomingVideoUrls.length > 0); if ( (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) && !shouldDeferClearForActiveMedia ) { clearStreamImageGenerationStateForChat(eventChatId); } if (!isEventForActiveChat) { return; } 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 sortCanvasMessagesByCreatedAt(nextMessages); } const createdAtValue = messageData.createdAt || messageData.created_at || messageData.timestamp || new Date(); const newMessage: any = { id: messageData.id, role: messageData.role, content: normalizedParts.find((part: any) => part?.type === "text")?.text || "", createdAt: createdAtValue, parts: normalizedParts, attachments: normalizedAttachments, }; return sortCanvasMessagesByCreatedAt([...prevMessages, newMessage]); }); } catch (e) { console.error("[CanvasChat] Failed to parse data-appendMessage:", e); } return; } if (dataPart.type === "data-videoStoryboard") { try { const payload: VideoStoryboardStreamData = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; if (!payload || typeof payload !== "object") return; if (payload.stage === "start") { const taskId = String(payload.taskId || "").trim(); if (!taskId) return; if (storyboardNodeMapRef.current[taskId]) return; const totalShots = Math.max(1, Math.min(9, Number(payload.totalShots || 9))); const layoutCols = Math.max(1, Math.min(3, Number(payload.cols || (totalShots === 4 ? 2 : 3)))) || 3; const shotNodeMap: Record = {}; const currentNodes = useCanvasStore.getState().nodes; const baseOffset = currentNodes.length; const cardWidth = 320; const cardHeight = 196; const gapX = 30; const gapY = 24; const startX = 120 + (baseOffset % 2) * 420; const startY = 120 + Math.floor(baseOffset / 2) * 260; for (let shotIndex = 1; shotIndex <= totalShots; shotIndex += 1) { const shotData = payload.shots?.find((item) => Number(item.shotIndex) === shotIndex); const col = (shotIndex - 1) % layoutCols; const row = Math.floor((shotIndex - 1) / layoutCols); const nodeId = addNode({ type: "image", position: { x: startX + col * (cardWidth + gapX), y: startY + row * (cardHeight + gapY), }, size: { width: cardWidth, height: cardHeight }, data: { src: shotData?.tileImageUrl || "", status: "generating", title: `镜头 ${shotIndex}`, prompt: shotData?.prompt || `分镜 ${shotIndex} 生成中`, originalSrc: shotData?.tileImageUrl || "", metadata: { storyboardTaskId: taskId, shotIndex, stage: "preview", }, }, connections: [], }); shotNodeMap[shotIndex] = nodeId; } storyboardNodeMapRef.current[taskId] = shotNodeMap; toast.message(`分镜预览任务已开始(${totalShots} 段)`); return; } if (payload.stage === "hd_shot_completed") { const taskId = String(payload.taskId || "").trim(); const shotIndex = Math.max(1, Number(payload.shotIndex || 0)); const imageUrl = String(payload.imageUrl || "").trim(); if (!taskId || !shotIndex || !imageUrl) return; let nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex]; if (!nodeId) { nodeId = addNode({ type: "image", position: { x: 140 + ((shotIndex - 1) % 3) * 350, y: 150 + Math.floor((shotIndex - 1) / 3) * 230 }, size: { width: 320, height: 196 }, data: { src: imageUrl, status: "completed", title: `镜头 ${shotIndex}`, prompt: payload.prompt || `镜头 ${shotIndex}`, metadata: { storyboardTaskId: taskId, shotIndex, stage: "preview", }, }, connections: [], }); if (!storyboardNodeMapRef.current[taskId]) { storyboardNodeMapRef.current[taskId] = {}; } storyboardNodeMapRef.current[taskId]![shotIndex] = nodeId; return; } const currentNode = useCanvasStore.getState().getNode(nodeId); const currentData = ((currentNode?.data as any) || {}) as Record; updateNode(nodeId, { data: { ...currentData, src: imageUrl, status: "completed", title: currentData.title || `镜头 ${shotIndex}`, prompt: payload.prompt || currentData.prompt || `镜头 ${shotIndex}`, metadata: { ...(currentData.metadata || {}), storyboardTaskId: taskId, shotIndex, stage: "preview", }, }, } as any); return; } if (payload.stage === "hd_shot_failed") { const taskId = String(payload.taskId || "").trim(); const shotIndex = Math.max(1, Number(payload.shotIndex || 0)); if (!taskId || !shotIndex) return; const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex]; if (!nodeId) return; const currentNode = useCanvasStore.getState().getNode(nodeId); const currentData = ((currentNode?.data as any) || {}) as Record; updateNode(nodeId, { data: { ...currentData, status: "failed", metadata: { ...(currentData.metadata || {}), previewError: payload.error || "预览图生成失败", }, }, } as any); return; } if (payload.stage === "awaiting_confirmation") { const taskId = String(payload.taskId || "").trim(); if (!taskId) return; const previewShots = Array.isArray(payload.shots) ? payload.shots : []; for (const shot of previewShots) { const shotIndex = Math.max(1, Number(shot.shotIndex || 0)); const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex]; if (!nodeId) continue; const currentNode = useCanvasStore.getState().getNode(nodeId); const currentData = ((currentNode?.data as any) || {}) as Record; updateNode(nodeId, { data: { ...currentData, src: String(shot.previewImageUrl || currentData.src || shot.tileImageUrl || ""), originalSrc: String(shot.tileImageUrl || currentData.originalSrc || ""), status: "completed", title: currentData.title || `镜头 ${shotIndex}`, prompt: shot.prompt || currentData.prompt || `镜头 ${shotIndex}`, metadata: { ...(currentData.metadata || {}), storyboardTaskId: taskId, shotIndex, stage: "awaiting_confirmation", }, }, } as any); } toast.message(payload.message || "分镜预览已完成,请先确认镜头内容"); return; } if (payload.stage === "shot_completed") { const taskId = String(payload.taskId || "").trim(); const shotIndex = Math.max(1, Number(payload.shotIndex || 0)); const videoUrl = String(payload.videoUrl || "").trim(); if (!taskId || !shotIndex || !videoUrl) return; let nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex]; if (!nodeId) { nodeId = addNode({ type: "video", position: { x: 140 + ((shotIndex - 1) % 3) * 350, y: 150 + Math.floor((shotIndex - 1) / 3) * 230 }, size: { width: 320, height: 196 }, data: { src: videoUrl, status: "completed", prompt: `分镜 ${shotIndex}`, }, connections: [], }); if (!storyboardNodeMapRef.current[taskId]) { storyboardNodeMapRef.current[taskId] = {}; } storyboardNodeMapRef.current[taskId]![shotIndex] = nodeId; return; } const currentNode = useCanvasStore.getState().getNode(nodeId); const currentData = ((currentNode?.data as any) || {}) as Record; updateNode(nodeId, { type: "video", data: { src: videoUrl, status: "completed", prompt: currentData.prompt || `分镜 ${shotIndex}`, coverImage: currentData.src || currentData.originalSrc || "", metadata: { ...(currentData.metadata || {}), storyboardTaskId: taskId, shotIndex, stage: "video", }, }, } as any); return; } if (payload.stage === "shot_failed") { const taskId = String(payload.taskId || "").trim(); const shotIndex = Math.max(1, Number(payload.shotIndex || 0)); if (!taskId || !shotIndex) return; const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex]; if (!nodeId) return; const currentNode = useCanvasStore.getState().getNode(nodeId); const currentData = ((currentNode?.data as any) || {}) as Record; 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); } } }, onFinish: async () => { const finishedChatId = inFlightChatIdRef.current || activeChatId; const finishedRawChatId = inFlightRawChatIdRef.current || toRawCanvasChatId(finishedChatId); if (!finishedRawChatId) return; const syncMessagesFromDatabase = (dbMessages: ChatMessage[]) => { if (finishedChatId !== activeChatIdRef.current) { void mutate(`/api/messages?chatId=${finishedRawChatId}`); return; } const historySignature = buildCanvasHistoryMessagesSignature(finishedRawChatId, dbMessages); loadedHistorySignatureRef.current = historySignature; processedMessageIds.current = new Set(); hasHydratedHistoryRef.current = true; setMessages(sortCanvasMessagesByCreatedAt(dbMessages)); if (dbMessages.length > 0) { requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" }); }); } }; const reloadMessagesOnce = async () => { const response = await fetch(`/api/messages?chatId=${finishedRawChatId}&_t=${Date.now()}`, { cache: "no-store", }); if (!response.ok) { return { hasResult: false, hasError: false }; } const data = await response.json().catch(() => ({})); if (!Array.isArray(data?.messages)) { return { hasResult: false, hasError: false }; } const dbMessages = data.messages as ChatMessage[]; syncMessagesFromDatabase(dbMessages); const assistantMessages = getCanvasAssistantMessagesAfterTargetUser(dbMessages); const hasResult = assistantMessages.some((message) => { if (hasCanvasImageMarkdown((message as any).parts)) return true; const text = ((message as any).parts || []) .filter((part: any) => part?.type === "text" && typeof part.text === "string") .map((part: any) => String(part.text)) .join("\n"); return extractAssistantImageUrls(text).length > 0 || extractVideoUrls(text).length > 0; }); const hasError = assistantMessages.some((message) => hasCanvasLikelyGenerationErrorMessage(message), ); return { hasResult, hasError }; }; try { await new Promise((resolve) => window.setTimeout(resolve, 450)); let { hasResult, hasError } = await reloadMessagesOnce(); if (!hasResult && !hasError) { for (let attempt = 0; attempt < 6; attempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, 500)); const next = await reloadMessagesOnce(); hasResult = next.hasResult; hasError = next.hasError; if (hasResult || hasError) { break; } } } } catch (error) { console.error("[CanvasChat] Failed to sync messages after finish:", error); } finally { if (inFlightChatIdRef.current === finishedChatId) { inFlightChatIdRef.current = null; inFlightRawChatIdRef.current = null; } } }, onError: (error) => { const failedChatId = inFlightChatIdRef.current || activeChatId; clearStreamImageGenerationStateForChat(failedChatId); setAgentProgressStateForChat(failedChatId, { stage: "failed", currentStep: "execute", title: "本轮请求失败", message: error instanceof Error ? error.message : "请求失败,请稍后重试", }); releaseSendGuard(failedChatId); if (inFlightChatIdRef.current === failedChatId) { inFlightChatIdRef.current = null; inFlightRawChatIdRef.current = null; } 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, }); } if (failedChatId === activeChatIdRef.current) { toast.error(fallbackMessage || "生成失败"); } }, }); useEffect(() => { if (status === "ready") { releaseSendGuard(activeChatId); } }, [activeChatId, releaseSendGuard, status]); useEffect(() => { const launchPayload = pendingLaunchRef.current; if (!launchPayload?.autoSend) return; if (!selectedModelId || status !== "ready") return; const launchAttachments = (launchPayload.attachments || []) .map((attachment) => sanitizeChatAttachment(attachment as PickedObjectAttachment)) .filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment)); const textContent = clampChatRequestText(launchPayload.prompt?.trim() || "", MAX_CHAT_TEXT_PART_LENGTH) || (launchAttachments.length > 0 ? "请基于这些图片继续生成设计方案" : ""); if (!textContent && launchAttachments.length === 0) { pendingLaunchRef.current = null; return; } const parts: any[] = launchAttachments.map((attachment) => { const width = Number(attachment.width); const height = Number(attachment.height); return { type: "file" as const, mediaType: normalizeChatMediaType(attachment.contentType), name: normalizeChatAttachmentName(attachment.name), url: attachment.url, assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, width: Number.isFinite(width) && width > 0 ? Math.round(width) : undefined, height: Number.isFinite(height) && height > 0 ? Math.round(height) : undefined, }; }); if (textContent) { parts.push({ type: "text" as const, text: textContent }); } // 强制同步当前模型,避免模型切换后瞬时发送命中旧模型 currentModelIdRef.current = selectedModelId; inFlightChatIdRef.current = activeChatId; inFlightRawChatIdRef.current = rawChatId; sendMessage({ role: "user", parts, }); hasHydratedHistoryRef.current = true; pendingLaunchRef.current = null; setInput(""); setAttachments([]); clearSelection(); const editableDiv = textareaRef.current as HTMLDivElement | null; if (editableDiv) { editableDiv.innerHTML = ""; } }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, clearSelection, launchAutoSendTick]); // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) const loadedHistorySignatureRef = useRef(""); useEffect(() => { if (historyScrollTimerRef.current) { clearTimeout(historyScrollTimerRef.current); historyScrollTimerRef.current = null; } if (!historyData || !rawChatId) return; const shouldForceHydrate = forceHistoryHydrationRef.current === rawChatId; if ((status === "streaming" || status === "submitted") && !shouldForceHydrate) return; const historyMessages = historyData.messages || []; const signature = buildCanvasHistoryMessagesSignature(rawChatId, historyMessages); if (loadedHistorySignatureRef.current === signature) return; loadedHistorySignatureRef.current = signature; processedMessageIds.current = new Set(); hasHydratedHistoryRef.current = true; forceHistoryHydrationRef.current = null; setMessages(sortCanvasMessagesByCreatedAt(historyMessages)); setMessageViewportKey((prev) => prev + 1); console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); if (historyMessages.length > 0) { 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]); useEffect(() => { const streamState = streamImageGenerationStateByChatId[activeChatId]; if (!rawChatId || !streamState) return; let cancelled = false; const poll = async () => { if (cancelled) return; try { await mutateHistoryData(); } catch (error) { console.error("[CanvasChat] Failed to poll message history:", error); } }; void poll(); const timer = window.setInterval(poll, 1500); return () => { cancelled = true; window.clearInterval(timer); }; }, [activeChatId, rawChatId, streamImageGenerationStateByChatId, mutateHistoryData]); useEffect(() => { const streamState = streamImageGenerationStateByChatId[activeChatId]; if (!streamState || messages.length === 0) return; const lastUserIndex = [...messages] .map((message) => message.role) .lastIndexOf("user"); const assistantMessages = lastUserIndex >= 0 ? messages.slice(lastUserIndex + 1).filter((message) => message.role === "assistant") : messages.filter((message) => message.role === "assistant"); const hasCompletedMedia = assistantMessages.some((message) => { const parts = (message.parts || []) as any[]; const textContent = parts .filter((part) => part?.type === "text" && typeof part?.text === "string") .map((part) => String(part.text)) .join("\n"); return ( extractAssistantImageUrls(textContent).length > 0 || extractVideoUrls(textContent).length > 0 || /失败|超时|重试|error|failed/i.test(textContent) ); }); if (hasCompletedMedia) { const hasActivePlaceholder = useCanvasStore.getState().nodes.some((node) => { if (node.type !== "image") return false; const metadata = ((node.data as any)?.metadata || {}) as Record; return ( metadata.canvasGenerationPlaceholder === true && (!metadata.chatId || String(metadata.chatId) === activeChatId) ); }); if (hasActivePlaceholder) return; clearStreamImageGenerationStateForChat(activeChatId); } }, [ activeChatId, messages, streamImageGenerationStateByChatId, clearStreamImageGenerationStateForChat, ]); // 监听消息变化,提取图片并添加到画布 // 仅处理历史同步后的新消息,避免刷新后把历史图片重复回灌到画布 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 suppressedMediaKeys = new Set( (useCanvasStore.getState().getCanvasState().suppressedMediaKeys || []) .map((item) => String(item || "").trim()) .filter(Boolean) ); 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 insertedNodeIds: string[] = []; const completedAt = Date.now(); const messagePlaceholderNodes = useCanvasStore.getState().nodes.filter((node) => { if (node.type !== "image") return false; const metadata = ((node.data as any)?.metadata || {}) as Record; return ( metadata.canvasGenerationPlaceholder === true && (!metadata.chatId || String(metadata.chatId) === activeChatId) ); }); const newImageEntries = imageUrls.reduce>( (acc, url) => { const sourceImageKey = normalizeImageIdentityUrl(url); if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) { return acc; } const messageImageKey = `${message.id}::${sourceImageKey}`; if ( suppressedMediaKeys.has(`image:${messageImageKey}`) || suppressedMediaKeys.has(`image-url:${url}`) ) { return acc; } if (existingMessageImageKeys.has(messageImageKey)) { return acc; } existingImageUrlKeys.add(sourceImageKey); existingMessageImageKeys.add(messageImageKey); acc.push({ url, sourceImageKey }); return acc; }, [] ); if (newImageEntries.length === 0 && imageUrls.length > 0 && messagePlaceholderNodes.length > 0) { removeCanvasGenerationPlaceholders(activeChatId); clearStreamImageGenerationStateForChat(activeChatId); } 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 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 currentNodes = useCanvasStore.getState().nodes; const anchorNode = findCanvasImageAnchorNode(currentNodes, { selectedNodes: generationAnchorNodesRef.current.length > 0 ? generationAnchorNodesRef.current : selectedImageNodesRef.current, sourceImageUrls: nearestUserImageUrls, }); const reservedPlaceholderNode = messagePlaceholderNodes[index]; const placeholderNode = reservedPlaceholderNode ? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null : null; const size = placeholderNode ? { width: dimensions.width, height: dimensions.height } : anchorNode ? { width: Math.max( 1, Math.round(Number(anchorNode.size?.width || dimensions.width)) ), height: Math.max( 1, Math.round(Number(anchorNode.size?.height || dimensions.height)) ), } : { width: dimensions.width, height: dimensions.height }; const position = placeholderNode ? getCenteredReplacementPosition(placeholderNode, size) : getAnchoredGenerationPosition({ anchorNode, existingNodes: currentNodes, fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount, index, size, viewport: useCanvasStore.getState().viewport, }); const data = { src: url, status: "completed" as const, 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, sourceNodeId: sourceNodeId || anchorNode?.id || undefined, placementMode: "anchored-generation", recentlyGeneratedAt: completedAt, latestGeneratedHighlight: true, }, }; const connections = sourceNodeId ? [sourceNodeId] : anchorNode?.id ? [anchorNode.id] : []; const nodeId = placeholderNode ? placeholderNode.id : addNode({ type: "image", position, size, data, connections, }); if (placeholderNode) { updateNode(nodeId, { position, size, data, connections, }); } insertedNodeIds.push(nodeId); void autoNameGeneratedImageNode(nodeId, url); addedImageCount += 1; } if (insertedNodeIds.length > 0) { selectNodes(insertedNodeIds); const focusNode = useCanvasStore .getState() .nodes.find((node) => node.id === insertedNodeIds[0]); if (focusNode) { focusCanvasOnNode(focusNode); } clearStreamImageGenerationStateForChat(activeChatId); } const newVideoUrls = videoUrls.filter((url) => { const key = normalizeMediaUrl(url); if (!key || existingVideoUrlKeys.has(key)) return false; if (suppressedMediaKeys.has(`video:${url}`)) 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; } } }; void processNewAssistantMessages(); return () => { cancelled = true; }; }, [ messages, addNode, nodes, autoNameGeneratedImageNode, clearStreamImageGenerationStateForChat, focusCanvasOnNode, selectNodes, updateNode, ]); 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(() => { if (messages.length === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); // 标记是否已经为当前画布设置过标题(避免重复调用) const hasSetCanvasTitle = useRef(false); // 🆕 Agent模式上下文(画布图片布局) const buildAutoContext = useCallback(() => { const imageNodes = nodes.filter((n) => isCanvasImageReadyForAi(n)); 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 selectedNodeIdSet = new Set(selectedNodeIds); const imageNodes = nodes.filter( (node) => node.type === "image" && isValidChatHttpUrl(String((node.data as any)?.src || "").trim()), ); const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const shouldIsolateDirectModeContext = effectiveComposerMode !== "agent"; const selectedImageNodesForContext = selectedImageNodes.filter((node) => isValidChatHttpUrl(String((node.data as any)?.src || "").trim()) ); if (shouldIsolateDirectModeContext && selectedImageNodesForContext.length === 0) { generationAnchorNodesRef.current = []; return undefined; } const MAX_CONTEXT_IMAGES = 12; const MAX_AGENT_CANDIDATE_NODES = 30; const selectedCanvasNodes = nodes .filter((node) => selectedNodeIdSet.has(node.id)) .slice(0, 20); const selectedContextNodes = selectedImageNodesForContext.slice(0, 20); generationAnchorNodesRef.current = selectedContextNodes.slice(0, 4); const selectedIds = new Set(selectedContextNodes.map((node) => node.id)); const prioritizedNodes = shouldIsolateDirectModeContext ? selectedContextNodes : selectedContextNodes.length > 0 ? [ ...selectedContextNodes, ...imageNodes.filter((node) => !selectedIds.has(node.id)), ] : imageNodes; const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES); const candidateSourceNodes = [ ...selectedCanvasNodes, ...contextNodes.filter((node) => !selectedNodeIdSet.has(node.id)), ]; const uniqueCandidateSourceNodes = Array.from( new Map(candidateSourceNodes.map((node) => [node.id, node])).values() ).slice(0, MAX_AGENT_CANDIDATE_NODES); const selectedAssetIds = Array.from( new Set( selectedContextNodes .map((node) => { const assetId = (node.data as any)?.assetId; return isValidChatUuid(assetId) ? assetId.trim() : null; }) .filter((value): value is string => Boolean(value)), ), ); const projectId = isValidChatUuid(canvasId) ? canvasId : undefined; const toAgentNodeRef = (node: CanvasNode): CanvasAgentNodeRef | null => { const data = (node.data || {}) as Record; const type = String(node.type) as CanvasAgentNodeType; const metadata = data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata) ? data.metadata : undefined; const srcCandidate = typeof data.src === "string" ? data.src : typeof data.previewImage === "string" ? data.previewImage : typeof data.imageUrl === "string" ? data.imageUrl : typeof data.worldUrl === "string" ? data.worldUrl : typeof data.renderArtifacts?.screenshotUrl === "string" ? data.renderArtifacts.screenshotUrl : ""; const url = isValidChatHttpUrl(String(srcCandidate || "").trim()) ? String(srcCandidate).trim() : undefined; const titleCandidate = typeof data.title === "string" ? data.title : typeof data.content === "string" ? data.content : typeof data.prompt === "string" ? data.prompt : undefined; return { nodeId: node.id, type, assetId: isValidChatUuid(data.assetId) ? String(data.assetId).trim() : undefined, url, title: typeof titleCandidate === "string" ? String(titleCandidate).trim().slice(0, 256) : undefined, prompt: typeof data.prompt === "string" ? String(data.prompt).trim().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: selectedNodeIdSet.has(node.id), metadata, }; }; const toAgentSlotRef = (node: CanvasNode): CanvasAgentSlotRef | null => { if (String(node.type) !== "agent-slot") return null; const data = (node.data || {}) as Record; const slotType = data.slotType === "video" || data.slotType === "ui-screen" ? data.slotType : "image"; const fillPolicy = data.fillPolicy === "append-version" || data.fillPolicy === "replace-existing" ? data.fillPolicy : "replace-empty"; return { nodeId: node.id, slotType, label: typeof data.label === "string" ? String(data.label).slice(0, 128) : undefined, x: Number(node.position.x || 0), y: Number(node.position.y || 0), width: Number(node.size?.width || 0) || 1, height: Number(node.size?.height || 0) || 1, aspectRatio: typeof data.aspectRatio === "string" ? String(data.aspectRatio).slice(0, 32) : "1:1", isSelected: selectedNodeIdSet.has(node.id), fillPolicy, acceptedSources: Array.isArray(data.acceptedSources) ? data.acceptedSources .filter( (value: unknown): value is "text" | "image" | "selection" | "annotation" => value === "text" || value === "image" || value === "selection" || value === "annotation" ) .slice(0, 8) : undefined, }; }; const toAgentAnnotations = (node: CanvasNode): CanvasAgentAnnotationRef[] => { const metadata = ((node.data as any)?.metadata || {}) as Record; const annotations = Array.isArray(metadata.annotations) ? metadata.annotations : []; return annotations .map((annotation, index): CanvasAgentAnnotationRef | null => { if (!annotation || typeof annotation !== "object") return null; const type = String(annotation.type || "text-note"); if ( type !== "text-note" && type !== "arrow-note" && type !== "box" && type !== "freehand" && type !== "crop-box" ) { return null; } const bounds = annotation.bounds && typeof annotation.bounds === "object" ? { x: Number(annotation.bounds.x || 0), y: Number(annotation.bounds.y || 0), width: Number(annotation.bounds.width || 0), height: Number(annotation.bounds.height || 0), } : undefined; const targetPoint = annotation.targetPoint && typeof annotation.targetPoint === "object" ? { x: Number(annotation.targetPoint.x || 0), y: Number(annotation.targetPoint.y || 0), } : undefined; return { id: String(annotation.id || `${node.id}-annotation-${index}`).slice(0, 128), sourceNodeId: typeof annotation.sourceNodeId === "string" ? String(annotation.sourceNodeId).slice(0, 128) : node.id, type: type as CanvasAgentAnnotationRef["type"], text: typeof annotation.text === "string" ? String(annotation.text).slice(0, 1000) : undefined, bounds, targetPoint, color: typeof annotation.color === "string" ? String(annotation.color).slice(0, 64) : undefined, createdAt: typeof annotation.createdAt === "string" ? String(annotation.createdAt).slice(0, 64) : undefined, }; }) .filter((annotation): annotation is CanvasAgentAnnotationRef => Boolean(annotation)) .slice(0, 20); }; const selectedAgentNodes = selectedCanvasNodes .map(toAgentNodeRef) .filter((node): node is CanvasAgentNodeRef => Boolean(node)); const candidateAgentNodes = uniqueCandidateSourceNodes .map(toAgentNodeRef) .filter((node): node is CanvasAgentNodeRef => Boolean(node)); const agentSlots = nodes .map(toAgentSlotRef) .filter((slot): slot is CanvasAgentSlotRef => Boolean(slot)) .slice(0, 20); const agentAnnotations = uniqueCandidateSourceNodes .flatMap(toAgentAnnotations) .slice(0, 20); const selectedAgentSlotCount = agentSlots.filter( (slot) => slot.isSelected || selectedNodeIdSet.has(slot.nodeId) ).length; const agentFocusPolicy = selectedAgentSlotCount > 0 ? ("slot" as const) : selectedCanvasNodes.length > 0 ? ("selected" as const) : ("auto" as const); const canvasAgentContext: CanvasAgentContext = { version: 1, source: "canvas", projectId, canvasRevision: canvasRevision || undefined, canvasUpdatedAt: canvasUpdatedAt || undefined, viewport: { x: Number(viewport.x || 0), y: Number(viewport.y || 0), zoom: Number(viewport.zoom || 1), width: typeof window !== "undefined" ? window.innerWidth : undefined, height: typeof window !== "undefined" ? window.innerHeight : undefined, }, selectedNodeIds: selectedCanvasNodes.map((node) => node.id).slice(0, 20), selectedNodes: selectedAgentNodes, candidateNodes: candidateAgentNodes, slots: agentSlots, annotations: agentAnnotations, focusPolicy: agentFocusPolicy, }; const hasAgentContext = canvasAgentContext.selectedNodes.length > 0 || canvasAgentContext.candidateNodes.length > 0 || canvasAgentContext.slots.length > 0 || canvasAgentContext.annotations.length > 0 || Boolean(canvasAgentContext.viewport); if (contextNodes.length === 0 && !hasAgentContext) { generationAnchorNodesRef.current = []; return undefined; } return { source: "canvas" as const, projectId, selectedNodeIds: selectedCanvasNodes.map((node) => node.id).slice(0, 20), selectedAssetIds, focusPolicy: agentFocusPolicy, canvasImageNodes: contextNodes.map((node) => ({ nodeId: node.id, assetId: isValidChatUuid((node.data as any)?.assetId) ? String((node.data as any).assetId).trim() : undefined, url: String((node.data as any)?.src || "").trim(), 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), })), canvasAgentContext, }; }, [ activeComposerMode, canvasId, canvasRevision, canvasUpdatedAt, nodes, selectedImageNodes, selectedNodeIds, viewport, ]); 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 updateAttachmentAtAgentSlot = useCallback( (slotIndex: number, attachment: PickedObjectAttachment | null) => { const slotCount = Math.max(agentUploadSlots.length, 1); 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)); }); }, [agentUploadSlots.length] ); const fillVideoSlotFromCanvasSelection = useCallback( (slotIndex: number) => { const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0]; const attachment = preferredNode ? getCanvasNodeAttachment(preferredNode, `参考图 ${slotIndex + 1}`) : null; if (!attachment) return false; updateAttachmentAtVideoSlot(slotIndex, attachment); return true; }, [getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot] ); const fillAgentSlotFromCanvasSelection = useCallback( (slotIndex: number) => { const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0]; const attachment = preferredNode ? getCanvasNodeAttachment(preferredNode, `风格化图片 ${slotIndex + 1}`) : null; if (!attachment) return false; updateAttachmentAtAgentSlot(slotIndex, attachment); return true; }, [getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtAgentSlot] ); const handleVideoSlotFileChange = useCallback( async (slotIndex: number, event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const validation = validateCanvasImageUploadFile(file); if (!validation.ok) { toast.error(validation.message); event.currentTarget.value = ""; return; } setVideoSlotUploading(slotIndex); try { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error("上传失败"); } const data = await response.json(); const imageUrl = data.url as string; const assetId = normalizeUploadedAssetId(data.assetId); updateAttachmentAtVideoSlot( slotIndex, buildCanvasUploadedImageAttachment({ file, imageUrl, 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: getCanvasUploadImageTitle(file.name), prompt: getCanvasUploadImageTitle(file.name), metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, }, }, connections: [], }); toast.success(`${slotIndex === 0 ? "首帧" : "尾帧"}上传成功`); } catch (_error) { toast.error("图片上传失败"); } finally { setVideoSlotUploading(null); event.currentTarget.value = ""; } }, [addNode, nodes.length, updateAttachmentAtVideoSlot] ); const handleAgentSlotFileChange = useCallback( async (slotIndex: number, event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const validation = validateCanvasImageUploadFile(file); if (!validation.ok) { toast.error(validation.message); event.currentTarget.value = ""; return; } setAgentSlotUploading(slotIndex); try { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error("上传失败"); } const data = await response.json(); const imageUrl = data.url as string; const assetId = normalizeUploadedAssetId(data.assetId); updateAttachmentAtAgentSlot( slotIndex, buildCanvasUploadedImageAttachment({ file, imageUrl, 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: getCanvasUploadImageTitle(file.name), prompt: getCanvasUploadImageTitle(file.name), metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, }, }, connections: [], }); const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`; toast.success(`${slotLabel}上传成功`); } catch (_error) { toast.error("图片上传失败"); } finally { setAgentSlotUploading(null); event.currentTarget.value = ""; } }, [addNode, agentUploadSlots, nodes.length, updateAttachmentAtAgentSlot] ); const updateDetailSlotAttachment = useCallback( (slotIndex: number, attachment: PickedObjectAttachment | null) => { setDetailSlots((current) => applyDetailSlotAttachment(current, slotIndex, attachment)); }, [] ); const fillDetailSlotsFromCanvasSelection = useCallback( (slotIndex: number) => { if (slotIndex < 0 || slotIndex >= DETAIL_SLOT_CONFIGS.length) return 0; if (selectedImageNodes.length === 0) return 0; const targetIndexes = getDetailSlotTargetIndexes(slotIndex); 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 result = fillDetailSlotAttachments(current, targetIndexes, candidateAttachments); filledCount = result.filledCount; return result.slots; }); 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; const validation = validateCanvasImageUploadFile(file); if (!validation.ok) { toast.error(validation.message); event.currentTarget.value = ""; return; } setDetailSlotUploading(slotIndex); try { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error("上传失败"); } const data = await response.json(); const imageUrl = String(data.url || "").trim(); const assetId = normalizeUploadedAssetId(data.assetId); if (!imageUrl) { throw new Error("未获取到图片地址"); } updateDetailSlotAttachment( slotIndex, buildCanvasUploadedImageAttachment({ file, imageUrl, 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) => patchDetailArchiveList(prev, archiveId, patch)); }, [] ); 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; uiSurfaceSnapshot?: UiSurfaceSnapshot | null; uiSurfaceLatestReview?: DetailArchive["uiSurfaceLatestReview"]; }): Promise => { const createdAt = Date.now(); const archive = createDetailArchiveSnapshot({ ...params, archiveId: generateUUID(), createdAt, }); setDetailArchives((prev) => prependDetailArchive(prev, archive)); const saved = await createDetailArchiveInDatabase(archive); if (saved?.id) { setDetailArchives((prev) => replaceSavedDetailArchive(prev, archive.id, saved)); return saved; } return archive; }, [createDetailArchiveInDatabase] ); const loadDetailArchive = useCallback((archive: DetailArchive) => { setIsDetailGenerationMode(true); setDetailGenerationMode(normalizeDetailGenerationMode(archive.generationMode)); setDetailBrief({ ...archive.brief }); setDetailSurfaceSnapshot(archive.uiSurfaceSnapshot || null); setDetailUiValidation({ plan: archive.uiSurfaceSnapshot?.validation ? { passed: archive.uiSurfaceSnapshot.validation.passed, issues: archive.uiSurfaceSnapshot.validation.issues || [], } : null, latestScreen: archive.uiSurfaceLatestReview?.screenIndex ? { screenIndex: archive.uiSurfaceLatestReview.screenIndex, action: archive.uiSurfaceLatestReview.action, passed: archive.uiSurfaceLatestReview.passed, issues: archive.uiSurfaceLatestReview.issues || [], } : null, }); 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[] = []; let currentDetailSurfaceSnapshot: UiSurfaceSnapshot | null = null; let latestSurfaceReviewState: DetailArchive["uiSurfaceLatestReview"] = null; const detailPlaceholderSpacing = 24; const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH; const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT; const detailPlaceholderStartX = 120; const detailStyleBoardStartY = 120; const detailPlaceholderStartY = detailStyleBoardStartY + DETAIL_STYLE_BOARD_HEIGHT + DETAIL_STYLE_BOARD_SPACING; 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); setDetailUiValidation({ plan: null, latestScreen: null, }); setDetailSurfaceSnapshot(null); 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, uiSurfaceSnapshot: null, uiSurfaceLatestReview: null, }); 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 ); currentDetailSurfaceSnapshot = planData.uiSurface || null; if (planData.brandStyleBoard) { addNode({ type: "ui-screen", position: { x: detailPlaceholderStartX, y: detailStyleBoardStartY, }, size: { width: DETAIL_STYLE_BOARD_WIDTH, height: DETAIL_STYLE_BOARD_HEIGHT, }, data: { status: "completed", document: planData.brandStyleBoard, source: { prompt: detailPrompt, model: "detail-agent-brand-style-board", }, evaluation: { score: planData.preflight?.designDirectorScore, issues: Array.isArray(planData.qualityChecks?.issues) ? planData.qualityChecks.issues.filter(Boolean) : undefined, }, metadata: { detailBrandStyleBoard: true, detailPlanSummary: planData.summary || "", detailGlobalStyle: planData.globalStyle || null, detailDesignSkill: planData.designSkill || null, detailGenerationMode: activeDetailGenerationMode, }, }, connections: [], }); } if (activeDetailGenerationMode !== detailGenerationMode) { setDetailGenerationMode(activeDetailGenerationMode); } 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 planUiIssues = Array.isArray(planData.uiValidation?.issues) ? planData.uiValidation.issues.filter(Boolean) : []; const preflightDesignScore = Number(planData.preflight?.designDirectorScore || 0); const preflightMarketingScore = Number(planData.preflight?.marketingDirectorScore || 0); const preflightPassed = planData.preflight?.passed !== false; setDetailUiValidation({ plan: { passed: planData.uiValidation?.passed !== false, issues: planUiIssues, }, latestScreen: null, }); setDetailSurfaceSnapshot(planData.uiSurface || null); if (workingArchive?.id && planData.uiSurface) { const plannedArchive: DetailArchive = { ...workingArchive, uiSurfaceSnapshot: planData.uiSurface, uiSurfaceLatestReview: null, }; workingArchive = plannedArchive; updateLocalDetailArchive(plannedArchive.id, { uiSurfaceSnapshot: plannedArchive.uiSurfaceSnapshot, uiSurfaceLatestReview: plannedArchive.uiSurfaceLatestReview, }); await patchDetailArchiveInDatabase(plannedArchive.id, plannedArchive); } setDetailProgress({ status: "running", stage: planData.qualityChecks?.passed === false ? `分镜预审未通过:${qualityIssues[0] || "已自动切换为稳定方案"}` : planData.uiValidation?.passed === false ? `协议排版校验提示:${planUiIssues[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 replaceDetailTextLayers = (params: { nodeId: string; drafts: Array>; }) => { const storeState = useCanvasStore.getState(); const childTextNodeIds = collectManagedDetailTextLayerNodeIds( storeState.nodes, params.nodeId, ); childTextNodeIds.forEach((nodeId) => deleteNode(nodeId)); params.drafts.forEach((draft) => addNode(draft)); }; const createAgentLayoutTextLayers = (params: { nodeId: string; screenIndex: number; screen?: DetailPlanScreenSpec; screenProtocol?: DetailUiProtocol["screens"][number]; surfaceSnapshot?: UiSurfaceSnapshot | null; }) => { if (!isAgentLayoutMode) return; const storeState = useCanvasStore.getState(); if (params.surfaceSnapshot) { replaceDetailTextLayers({ nodeId: params.nodeId, drafts: [], }); return; } const imageNode = storeState.getNode(params.nodeId); if (!imageNode) return; if (imageNode.type === "ui-screen") { replaceDetailTextLayers({ nodeId: params.nodeId, drafts: [], }); 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 height = Number(imageNode.size?.height || DETAIL_TARGET_HEIGHT); const drafts: Array> = []; const finalDrafts = drafts.length > 0 ? drafts : buildDetailTextLayerNodes({ parentNodeId: params.nodeId, parentPosition: { x, y }, parentSize: { width, height }, screenSize: planData.uiProtocol?.screenSize || { width: DETAIL_TARGET_WIDTH, height: DETAIL_TARGET_HEIGHT, }, screenIndex: params.screenIndex, screen: params.screen, screenProtocol: params.screenProtocol, typography: { titleFontSize, titleFontWeight, subtitleFontSize, subtitleFontWeight, bodyFontSize, bodyFontWeight, }, }); replaceDetailTextLayers({ nodeId: params.nodeId, drafts: finalDrafts, }); }; 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 ? "双总监预审重写完成" : "双总监预审通过"; const requestDetailSubjectAnalysis = async (imageUrl?: string) => { const normalized = String(imageUrl || "").trim(); if (!/^https?:\/\//i.test(normalized)) return null; try { const response = await fetch("/api/canvas/detail-subject-analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imageUrl: normalized }), }); const result = await response.json().catch(() => ({})); if (!response.ok || !result?.success) { return null; } return result.data || null; } catch (error) { console.warn("[CanvasChat] Detail subject analyze failed:", error); return null; } }; 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 = sanitizeDetailImageModelPrompt([ 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 sanitizedPrompt = sanitizeDetailImageModelPrompt(promptOverride); const generationReferenceImages = [ ...generationReferenceImagesBase, ...extraReferenceImages, ].filter(Boolean); const result = await requestCanvasImageGeneration({ prompt: sanitizedPrompt, 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; const targetNodeId = detailPlaceholderNodeIds[index]; patchDetailPlaceholder(index, { label: isAgentLayoutMode ? `协议质检中 ${index + 1}/${prompts.length}` : `双总监审稿中 ${index + 1}/${prompts.length}`, status: "generating", }); setDetailProgress({ status: "running", stage: isAgentLayoutMode ? `第 ${index + 1}/${prompts.length} 屏协议质检中,Agent 正在检查背景与文字层协议...` : `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`, current: reviewStepCurrent, total: totalSteps, }); 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, ), ); try { const reviewResponse = await fetch("/api/canvas/detail-review", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ outputLanguage, generationMode: activeDetailGenerationMode, surfaceId: currentDetailSurfaceSnapshot?.surfaceId, 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); } if (screenReviewResult) { const latestScreenIssues = Array.isArray(screenReviewResult.uiValidation?.issues) ? screenReviewResult.uiValidation.issues.filter(Boolean) : Array.isArray(screenReviewResult.issues) ? screenReviewResult.issues.filter(Boolean) : []; latestSurfaceReviewState = { screenIndex: index + 1, action: screenReviewResult?.reviewAction, passed: typeof screenReviewResult.uiValidation?.passed === "boolean" ? screenReviewResult.uiValidation.passed : screenReviewResult.pass, issues: latestScreenIssues, }; if (screenReviewResult.uiSurfacePatch) { currentDetailSurfaceSnapshot = applyUiSurfacePatch( currentDetailSurfaceSnapshot, screenReviewResult.uiSurfacePatch, ); setDetailSurfaceSnapshot(currentDetailSurfaceSnapshot); } const nextLatestSurfaceReview = latestSurfaceReviewState || { screenIndex: index + 1, action: screenReviewResult?.reviewAction, passed: typeof screenReviewResult.uiValidation?.passed === "boolean" ? screenReviewResult.uiValidation.passed : screenReviewResult.pass, issues: latestScreenIssues, }; setDetailUiValidation((prev) => ({ ...prev, latestScreen: { screenIndex: nextLatestSurfaceReview.screenIndex || index + 1, action: nextLatestSurfaceReview.action, passed: nextLatestSurfaceReview.passed, issues: nextLatestSurfaceReview.issues || [], }, })); } const shouldApplyProtocolPatch = Boolean( screenReviewResult?.uiPatch && screenReviewResult?.reviewAction === "protocol_patch", ); const shouldRegenerate = !shouldApplyProtocolPatch && screenReviewResult?.reviewAction === "regenerate_image" && typeof screenReviewResult?.revisedPrompt === "string" && screenReviewResult.revisedPrompt.trim().length > 20; if (shouldApplyProtocolPatch) { 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, ), ); const currentNode = targetNodeId ? useCanvasStore.getState().getNode(targetNodeId) : null; if (currentNode && targetNodeId) { if (currentDetailSurfaceSnapshot) { createAgentLayoutTextLayers({ nodeId: targetNodeId, screenIndex: index, screen, screenProtocol: planData.uiProtocol?.screens?.[index], surfaceSnapshot: currentDetailSurfaceSnapshot, }); } else { const replacementDrafts = buildDetailTextLayerNodesFromReviewPatch({ parentNodeId: targetNodeId, parentPosition: { x: Number(currentNode.position?.x || 0), y: Number(currentNode.position?.y || 0), }, parentSize: { width: Number(currentNode.size?.width || DETAIL_TARGET_WIDTH), height: Number(currentNode.size?.height || DETAIL_TARGET_HEIGHT), }, screenSize: planData.uiProtocol?.screenSize || { width: DETAIL_TARGET_WIDTH, height: DETAIL_TARGET_HEIGHT, }, uiPatch: screenReviewResult?.uiPatch, }); if (replacementDrafts.length > 0) { replaceDetailTextLayers({ nodeId: targetNodeId, drafts: replacementDrafts, }); } } } } else 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 = sanitizeDetailImageModelPrompt( 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 if (isAgentLayoutMode) { 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, uiSurfaceSnapshot: currentDetailSurfaceSnapshot, uiSurfaceLatestReview: latestSurfaceReviewState, }; workingArchive = nextArchive; updateLocalDetailArchive(nextArchive.id, { summary: progressSummary, imageCount: completedDetailImageCount, coverUrl: progressCoverUrl, uiSurfaceSnapshot: currentDetailSurfaceSnapshot, uiSurfaceLatestReview: latestSurfaceReviewState, }); await patchDetailArchiveInDatabase(nextArchive.id, nextArchive); } const dimensions = await getImageDimensions( finalGenerated.originalUrl || finalGenerated.thumbnailUrl ); const detailSubjectAnalysis = isAgentLayoutMode ? await requestDetailSubjectAnalysis( finalGenerated.originalUrl || finalGenerated.thumbnailUrl, ) : null; if (targetNodeId) { const storeState = useCanvasStore.getState(); const currentNode = storeState.getNode(targetNodeId); const currentData = ((currentNode?.data as any) || {}) as Record; const finalImageUrl = finalGenerated.originalUrl || finalGenerated.thumbnailUrl; const detailUiScreenOverlay = buildDetailUiScreenOverlaySnapshot({ surface: currentDetailSurfaceSnapshot, screenIndex: index + 1, }); const detailUiDslDocument = Array.isArray(planData.uiDslV2Screens) && planData.uiDslV2Screens[index] ? planData.uiDslV2Screens[index] : detailUiScreenOverlay ? buildDetailUiDslV2({ overlay: detailUiScreenOverlay, subjectAnalysis: detailSubjectAnalysis, }) : null; const completedDetailMetadata = { ...(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, detailDesignSkill: planData.designSkill || null, detailGenerationMode: activeDetailGenerationMode, detailScreenReview: screenReviewResult || null, detailUiSurfaceId: currentDetailSurfaceSnapshot?.surfaceId || null, detailUiSurfaceType: currentDetailSurfaceSnapshot?.surfaceType || null, detailUiScreenOverlay, detailUiDslDocument, detailSubjectAnalysis, detailGeneratingPlaceholder: false, detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`, }; const uiScreenDocument = isAgentLayoutMode && detailUiDslDocument ? buildUiScreenDocumentFromDetailUiDsl({ dsl: detailUiDslDocument, background: { src: finalImageUrl, thumbnailUrl: finalGenerated.thumbnailUrl, alt: screen?.title || `详情图-${index + 1}`, }, title: screen?.title || `详情图-${index + 1}`, metadata: completedDetailMetadata, }) : null; updateNode(targetNodeId, { type: uiScreenDocument ? "ui-screen" : currentNode?.type || "image", size: { width: DETAIL_TARGET_WIDTH, height: DETAIL_TARGET_HEIGHT, }, data: uiScreenDocument ? { status: "completed", document: uiScreenDocument, renderArtifacts: { screenshotUrl: finalGenerated.originalUrl, thumbnailUrl: finalGenerated.thumbnailUrl, }, source: { prompt: finalGenerationPrompt, model: modelForGeneration, }, evaluation: { score: typeof screenReviewResult?.score === "number" ? screenReviewResult.score : undefined, issues: Array.isArray(screenReviewResult?.issues) ? screenReviewResult.issues.filter(Boolean) : undefined, }, metadata: completedDetailMetadata, } : { ...currentData, src: finalImageUrl, status: "completed", prompt: promptText, title: `详情图-${index + 1}`, metadata: completedDetailMetadata, }, }); createAgentLayoutTextLayers({ nodeId: targetNodeId, screenIndex: index, screen, screenProtocol: planData.uiProtocol?.screens?.[index], surfaceSnapshot: currentDetailSurfaceSnapshot, }); } } if (generatedImages.length !== prompts.length) { throw new Error( `详情主图未完整生成:计划 ${prompts.length} 屏,实际完成 ${generatedImages.length} 屏` ); } const reviewGroups: DetailReviewGroup[] = []; const reviewFailures: string[] = []; const detailStartX = 120; const detailStartY = detailPlaceholderStartY; 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, designSkill: planData.designSkill, qualityChecks: planData.qualityChecks, uiValidation: planData.uiValidation, brandStyleBoard: planData.brandStyleBoard, 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, uiSurfaceSnapshot: currentDetailSurfaceSnapshot, uiSurfaceLatestReview: latestSurfaceReviewState, }; workingArchive = finalArchive; updateLocalDetailArchive(finalArchive.id, { summary: finalArchive.summary, imageCount: finalArchive.imageCount, coverUrl: finalArchive.coverUrl, uiSurfaceSnapshot: finalArchive.uiSurfaceSnapshot, uiSurfaceLatestReview: finalArchive.uiSurfaceLatestReview, }); 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, uiSurfaceSnapshot: currentDetailSurfaceSnapshot, uiSurfaceLatestReview: latestSurfaceReviewState, }); } 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, uiSurfaceSnapshot: currentDetailSurfaceSnapshot, uiSurfaceLatestReview: latestSurfaceReviewState, }; updateLocalDetailArchive(failedArchive.id, { summary: failedSummary, imageCount: completedDetailImageCount, coverUrl: failedArchive.coverUrl, uiSurfaceSnapshot: failedArchive.uiSurfaceSnapshot, uiSurfaceLatestReview: failedArchive.uiSurfaceLatestReview, }); 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 = getImageCreditCostFromConfig( currentModel?.imageGenConfig, imageParams?.imageSize ); 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, imageParams?.imageCount, imageParams?.imageSize, isVideoModel, membershipSummary?.isHighVip, membershipSummary?.pointsSystemConfig?.blockOnInsufficient, membershipSummary?.pointsSystemConfig?.enabled, membershipSummary?.summary?.currentCredits, ]); const estimatedPointsCost = useMemo(() => { if (membershipSummary?.isHighVip) return null; if (membershipSummary?.pointsSystemConfig?.enabled !== true) return null; if (activeComposerMode === "agent") return null; if (isVideoModel) { const cost = getGenerationCreditCostFromImageGenConfig( currentModel?.imageGenConfig, "video", { duration: videoParams?.duration, resolution: videoParams?.resolution, } ); return cost > 0 ? { cost, title: `本次预计消耗 ${cost} 积分`, } : null; } const unitCost = getImageCreditCostFromConfig( currentModel?.imageGenConfig, imageParams?.imageSize ); const imageCount = Math.max( 1, Math.round(Number(imageParams?.imageCount || 1)) ); const cost = unitCost * imageCount; return cost > 0 ? { cost, title: `本次预计消耗 ${cost} 积分(${unitCost} × ${imageCount} 张)`, } : null; }, [ activeComposerMode, currentModel?.imageGenConfig, imageParams?.imageCount, imageParams?.imageSize, isVideoModel, membershipSummary?.isHighVip, membershipSummary?.pointsSystemConfig?.enabled, videoParams?.duration, videoParams?.resolution, ]); // 处理发送(图片已在上传时添加到画布,这里只发送消息) const handleSend = useCallback(async () => { const currentSendGuard = getSendGuard(activeChatId); if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") { return; } if (isDetailGenerationMode && canUseDetailGeneration) { clearLatestGeneratedImageHighlights(); setSendGuard(activeChatId, { inFlight: true, signature: `detail:${activeChatId}:${Date.now()}`, timestamp: Date.now(), }); try { await handleDetailGeneration(); } finally { releaseSendGuard(activeChatId); } return; } if (!input.trim() && attachments.length === 0 && !selectedSkillTag && !hasWorkflowContext) { return; } const effectiveModelId = currentModelIdRef.current || selectedModelId; if (!effectiveModelId) { toast.error("请选择一个模型"); return; } if (!ensureImagePointsBeforeSend()) { return; } // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; let effectiveAttachments = attachments .map((attachment) => sanitizeChatAttachment(attachment)) .filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment)); if (hasAgentUploadSlots) { if (effectiveAttachments.length > agentRequiredUploadCount) { effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount); } if (effectiveAttachments.length < agentRequiredUploadCount) { toast.error("当前风格化需要上传 3 张图片:产品图1、产品图2、模特图"); return; } } const videoImageLimit = isVideoModel ? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability) : null; if (videoImageLimit) { if (videoImageLimit.max === 0) { effectiveAttachments = []; } else if (effectiveAttachments.length > videoImageLimit.max) { effectiveAttachments = effectiveAttachments.slice(0, videoImageLimit.max); } if (effectiveAttachments.length < videoImageLimit.min) { toast.error( videoImageLimit.min === 1 ? "当前视频模式需要至少 1 张参考图" : "当前视频模式需要 2 张首尾帧图片" ); return; } } // 🆕 检查是否有标注的图片 const annotatedNodes = selectedImageNodes.filter(node => (node.data as any)?.hasAnnotations); const hasAnnotatedImages = annotatedNodes.length > 0; // 添加文件部分(作为 file 类型的 part) // 🆕 收集物品信息用于构建隐藏提示 const pickedObjectInfos: string[] = []; effectiveAttachments.forEach((a) => { const att = a as PickedObjectAttachment; const attachmentWidth = Number(att.width); const attachmentHeight = Number(att.height); parts.push({ type: "file" as const, mediaType: normalizeChatMediaType(a.contentType), name: normalizeChatAttachmentName(a.name), url: a.url, assetId: isValidChatUuid(a.assetId) ? a.assetId : undefined, width: Number.isFinite(attachmentWidth) && attachmentWidth > 0 ? Math.round(attachmentWidth) : undefined, height: Number.isFinite(attachmentHeight) && attachmentHeight > 0 ? Math.round(attachmentHeight) : undefined, 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 && hasWorkflowContext && workflowContext?.summaryText) { textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`; } const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; const effectiveAgentEntryMode = agentEntryModeRef.current || agentEntryMode; if (!textContent && hasAgentUploadSlots && selectedAgentPreset) { textContent = `请按「${selectedAgentPreset.name}」风格化配置顺序处理产品图1、产品图2和模特图。`; } if (!textContent && selectedSkillTag) { textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`; } textContent = clampChatRequestText(textContent, MAX_CHAT_TEXT_PART_LENGTH); const requestModel = allModels.find((model) => model.id === effectiveModelId); const shouldShowOptimisticImageGeneration = effectiveComposerMode === "image" && Boolean(requestModel && isCanvasImageModel(requestModel)); // 🆕 隐藏提示词(不在 UI 中展示) const hiddenSegments: string[] = []; if (selectedSkillTag?.prompt) { hiddenSegments.push( `【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}` ); } if (hasWorkflowContext && workflowContext?.promptText) { hiddenSegments.push( `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。` ); } if ( effectiveComposerMode === "agent" && effectiveAgentEntryMode === "preset" && selectedAgentPreset?.type === "text" && selectedAgentPreset.slots?.length ) { const presetPrompt = selectedAgentPreset.slots .map((slot, index) => { const title = String(slot.title || slot.uploadLabel || `模板${index + 1}`).trim(); const promptText = String(slot.promptTemplate || "").trim(); if (!promptText) return ""; return `【风格化:${selectedAgentPreset.name}|${title}】\n${promptText}`; }) .filter(Boolean) .join("\n\n"); if (presetPrompt) { hiddenSegments.push(presetPrompt); } } if (pickedObjectInfos.length > 0) { const objectsPrompt = pickedObjectInfos.join('\n'); hiddenSegments.push( `【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。` ); console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个'); } if (hasAnnotatedImages) { hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】"); console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示'); } if (isDetailGenerationMode && canUseDetailGeneration) { hiddenSegments.push( [ "【AI详情生成模式】", "你正在处理电商详情页任务,请先规划分镜结构,再执行生图。", "要求:保持同一产品外观一致、风格一致、文案排版一致,按详情页叙事顺序输出结果。", ].join("\n") ); } const hiddenPrompt = hiddenSegments.join('\n\n').trim(); hiddenPromptRef.current = clampChatRequestText(hiddenPrompt, MAX_CHAT_HIDDEN_PROMPT_LENGTH) || null; const autoContext = buildAutoContext(); autoContextRef.current = clampChatRequestText(autoContext, MAX_CHAT_AUTO_CONTEXT_LENGTH) || null; contextPayloadRef.current = buildContextPayload() || 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: effectiveComposerMode, }); if (shouldBlockDuplicateSend(activeChatId, sendSignature)) { toast.message("检测到重复发送,已自动忽略"); return; } clearLatestGeneratedImageHighlights(); setSendGuard(activeChatId, { inFlight: true, signature: sendSignature, timestamp: Date.now(), }); inFlightChatIdRef.current = activeChatId; inFlightRawChatIdRef.current = rawChatId; clearStreamImageGenerationStateForChat(activeChatId); clearAgentProgressStateForChat(activeChatId); if (shouldShowOptimisticImageGeneration) { const imageParamSnapshot = imageParamsRef.current || imageParams; setStreamImageGenerationStateForChat(activeChatId, { phase: "processing", providerType: requestModel?.providerType, modelName: getAgentSafeModelName(requestModel?.displayName), imageCount: Math.max(1, Math.round(Number(imageParamSnapshot?.imageCount || 1))) || 1, }); } // 使用与 multimodal-input 相同的格式发送消息 try { sendMessage({ role: "user", parts: parts, }); } catch (error) { releaseSendGuard(activeChatId); if (shouldShowOptimisticImageGeneration) { clearStreamImageGenerationStateForChat(activeChatId); } if (inFlightChatIdRef.current === activeChatId) { inFlightChatIdRef.current = null; inFlightRawChatIdRef.current = null; } contextPayloadRef.current = null; 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, agentEntryMode, agentRequiredUploadCount, rawChatId, input, attachments, hasAgentUploadSlots, hasWorkflowContext, selectedSkillTag, selectedAgentPreset, selectedModelId, sendMessage, clearSelection, clearLatestGeneratedImageHighlights, buildAutoContext, buildContextPayload, allModels, isVideoModel, isCanvasImageModel, videoParams, videoFrameSlots.length, currentModel?.providerType, imageParams, selectedImageNodes, isDetailGenerationMode, canUseDetailGeneration, handleDetailGeneration, ensureImagePointsBeforeSend, getSendGuard, clearStreamImageGenerationStateForChat, clearAgentProgressStateForChat, setStreamImageGenerationStateForChat, computedComposerMode, releaseSendGuard, setSendGuard, shouldBlockDuplicateSend, status, workflowContext, ]); 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; width?: number; height?: number; } | 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); formData.append('purpose', 'canvas-upload'); 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, width: dimensions.originalWidth || dimensions.width, height: dimensions.originalHeight || dimensions.height, }; } catch (_error) { toast.error('图片上传失败'); return null; } }, [addNode]); const handlePastedImages = useCallback(async (files: File[]) => { if (files.length === 0) return; let firstImageUrl: string | null = null; const baseNodeCount = nodes.length; let uploadIndex = 0; if (hasAgentUploadSlots) { const slotCount = agentUploadSlots.length; const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); let handledCount = 0; for (const file of files) { const targetSlotIndex = currentSlots.findIndex((slot) => !slot); if (targetSlotIndex < 0) break; const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); if (!uploaded) continue; const attachment: PickedObjectAttachment = { url: uploaded.imageUrl, name: uploaded.name, contentType: uploaded.contentType, assetId: uploaded.assetId, width: uploaded.width, height: uploaded.height, }; updateAttachmentAtAgentSlot(targetSlotIndex, attachment); currentSlots[targetSlotIndex] = attachment; handledCount += 1; uploadIndex += 1; if (!firstImageUrl) { firstImageUrl = uploaded.imageUrl; } } if (handledCount > 0) { toast.success( handledCount === 1 ? "已粘贴图片到风格化槽位" : `已粘贴 ${handledCount} 张图片到风格化槽位` ); } if (handledCount < files.length) { toast.info("风格化图片槽位已满,剩余图片未添加"); } void maybeAutoSetCanvasTitle(firstImageUrl); return; } if (isVideoModel && videoFrameSlots.length > 0) { const slotCount = videoFrameSlots.length; const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); let handledCount = 0; for (const file of files) { const targetSlotIndex = currentSlots.findIndex((slot) => !slot); if (targetSlotIndex < 0) break; const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); if (!uploaded) continue; const attachment: PickedObjectAttachment = { url: uploaded.imageUrl, name: uploaded.name, contentType: uploaded.contentType, width: uploaded.width, height: uploaded.height, }; 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, assetId: uploaded.assetId, width: uploaded.width, height: uploaded.height, }, ]; }); } if (handledCount > 0) { toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`); } void maybeAutoSetCanvasTitle(firstImageUrl); }, [ agentUploadSlots.length, attachments, hasAgentUploadSlots, insertImageTagInEditor, isVideoModel, maybeAutoSetCanvasTitle, nodes.length, updateAttachmentAtAgentSlot, updateAttachmentAtVideoSlot, uploadImageFileAndAddToCanvas, videoFrameSlots.length, ]); // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; let firstImageUrl: string | null = null; // 记录本次上传开始时的节点数量,用于计算新图片位置 const baseNodeCount = nodes.length; let uploadIndex = 0; if (hasAgentUploadSlots) { const slotCount = agentUploadSlots.length; const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); let handledCount = 0; for (const file of Array.from(files)) { const targetSlotIndex = currentSlots.findIndex((slot) => !slot); if (targetSlotIndex < 0) break; const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex); if (!uploaded) continue; updateAttachmentAtAgentSlot(targetSlotIndex, { url: uploaded.imageUrl, name: uploaded.name, contentType: uploaded.contentType, assetId: uploaded.assetId, width: uploaded.width, height: uploaded.height, }); currentSlots[targetSlotIndex] = { url: uploaded.imageUrl, name: uploaded.name, contentType: uploaded.contentType, assetId: uploaded.assetId, width: uploaded.width, height: uploaded.height, }; handledCount += 1; uploadIndex += 1; if (!firstImageUrl) { firstImageUrl = uploaded.imageUrl; } } if (handledCount > 0) { toast.success( handledCount === 1 ? "图片已添加到风格化槽位" : `已添加 ${handledCount} 张图片到风格化槽位` ); } if (handledCount < files.length) { toast.info("风格化需要的图片槽位已满,剩余图片未添加"); } void maybeAutoSetCanvasTitle(firstImageUrl); e.target.value = ''; return; } for (const file of Array.from(files)) { if (!file.type.startsWith('image/')) { toast.error('请选择图片文件'); continue; } if (file.size > 50 * 1024 * 1024) { toast.error('图片大小不能超过 50MB'); continue; } // 上传文件 try { const formData = new FormData(); formData.append('file', file); formData.append('purpose', 'canvas-upload'); 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; const dimensions = await getImageDimensions(imageUrl); // 记录第一张图片的 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, width: dimensions.originalWidth || dimensions.width, height: dimensions.originalHeight || dimensions.height, }]; }); // 获取图片原始尺寸后添加到画布 // 使用 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( () => { const scopedMessages = isDetailGenerationMode ? messages.slice(detailModeMessageStartIndex) : messages; return sortCanvasMessagesByCreatedAt(scopedMessages); }, [detailModeMessageStartIndex, isDetailGenerationMode, messages] ); const isLoading = status === 'streaming' || status === 'submitted'; const isSubmitting = isLoading || isDetailGenerating; const hasInputOrAttachments = Boolean( input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag) ); const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount; const composerPlaceholder = isWorkspacePresentation ? isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : isVideoWorkspace ? "描述镜头、主体和运动方式,也可以先切到首帧或首尾帧模式" : activeComposerMode === "agent" && !isPresetAgentSelection ? "" : hasAgentUploadSlots ? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。" : "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。" : isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : isVideoWorkspace ? isVideoModel && videoFrameSlots.length > 0 ? `描述镜头、主体和运动方式;可从画布填充${videoFrameSlots.join(" / ")}` : "描述视频镜头、主体、运动方式与时长,也可以切换到首帧或首尾帧模式" : activeComposerMode === "agent" && !isPresetAgentSelection ? "" : activeComposerMode === "agent" && selectedAgentPreset?.type === "text" ? "可上传图片做分析,也可直接输入文案需求" : hasAgentUploadSlots ? "请先上传产品图1、产品图2、模特图,再补充说明" : selectedSkillTag?.placeholder?.trim() || (selectedSkillTag ? `请输入「${selectedSkillTag.name}」相关需求` : "请输入你的设计需求"); const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length; const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false; const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length; const detailProgressPercent = detailProgress.total > 0 ? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100)) : 0; const canSend = isDetailGenerationMode ? detailValidationIssues.length === 0 && !isDetailGenerating : hasInputOrAttachments && hasEnoughAgentUploads; const currentVideoImageLimit = isVideoModel ? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability) : null; const showVideoAttachmentButton = Boolean( isVideoModel && currentVideoImageLimit && currentVideoImageLimit.max > 0 && videoFrameSlots.length === 0 ); const lastUserIndexForMarkers = visibleMessages.findLastIndex(m => m.role === "user"); const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? visibleMessages.slice(lastUserIndexForMarkers + 1) : visibleMessages; const hasGenerationStart = messagesAfterLastUser.some(msg => msg.role === "assistant" && msg.parts?.some( (part: any) => part.type === "text" && part.text?.includes("") ) ); 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 loadingQueuePosition = streamImageGenerationState?.queuePosition; const loadingSameModelRunningCount = streamImageGenerationState?.sameModelRunningCount; const loadingConcurrencyLimit = streamImageGenerationState?.concurrencyLimit; const loadingEstimatedWaitMs = streamImageGenerationState?.estimatedWaitMs; const canvasGenerationPlaceholderState = useMemo(() => { if (!shouldShowImageLoading) return null; if (streamImageGenerationState) return streamImageGenerationState; return { phase: loadingPhase, providerType: loadingProviderType, modelName: loadingModelName, imageCount: loadingImageCount, queuePosition: loadingQueuePosition, sameModelRunningCount: loadingSameModelRunningCount, concurrencyLimit: loadingConcurrencyLimit, estimatedWaitMs: loadingEstimatedWaitMs, }; }, [ loadingConcurrencyLimit, loadingEstimatedWaitMs, loadingImageCount, loadingModelName, loadingPhase, loadingProviderType, loadingQueuePosition, loadingSameModelRunningCount, shouldShowImageLoading, streamImageGenerationState, ]); useEffect(() => { const streamState = canvasGenerationPlaceholderState; const placeholderId = canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; if (!streamState) { removeCanvasGenerationPlaceholders(activeChatId); return; } const phase = streamState.phase || "processing"; const label = phase === "queued" ? "等待开始生成" : phase === "finalizing" ? "正在润饰细节" : phase === "failed" ? "生成失败" : "正在创建图片"; const status = phase === "failed" ? "failed" : "generating"; const currentNodes = useCanvasStore.getState().nodes; const anchorNode = findCanvasImageAnchorNode(currentNodes, { selectedNodes: generationAnchorNodesRef.current.length > 0 ? generationAnchorNodesRef.current : selectedImageNodesRef.current, }); const imageParamSnapshot = imageParamsRef.current || imageParams; const hasExplicitAspectRatio = Boolean(imageParamSnapshot.aspectRatio) && imageParamSnapshot.aspectRatio !== "auto"; const aspectRatio = hasExplicitAspectRatio ? String(imageParamSnapshot.aspectRatio) : "1:1"; const [ratioWidthRaw, ratioHeightRaw] = aspectRatio.split(":").map((item) => Number(item)); const ratioWidth = Number.isFinite(ratioWidthRaw) && ratioWidthRaw > 0 ? ratioWidthRaw : 1; const ratioHeight = Number.isFinite(ratioHeightRaw) && ratioHeightRaw > 0 ? ratioHeightRaw : 1; const baseSide = 340; const ratioPlaceholderSize = ratioWidth >= ratioHeight ? { width: baseSide, height: Math.max(190, Math.round((baseSide * ratioHeight) / ratioWidth)), } : { width: Math.max(190, Math.round((baseSide * ratioWidth) / ratioHeight)), height: baseSide, }; const placeholderSize = anchorNode && !hasExplicitAspectRatio ? { width: Math.max( 180, Math.round(Number(anchorNode.size?.width || ratioPlaceholderSize.width)) ), height: Math.max( 180, Math.round(Number(anchorNode.size?.height || ratioPlaceholderSize.height)) ), } : ratioPlaceholderSize; const position = getAnchoredGenerationPosition({ anchorNode, existingNodes: currentNodes, excludeNodeIds: placeholderId ? [placeholderId] : [], fallbackNodeCount: currentNodes.length, size: placeholderSize, viewport: useCanvasStore.getState().viewport, }); const metadata = { canvasGenerationPlaceholder: true, chatId: activeChatId, detailProgressLabel: label, generationProgress: phase === "queued" ? 12 : phase === "finalizing" ? 96 : phase === "failed" ? 100 : undefined, modelName: streamState.modelName || currentModel?.displayName || undefined, providerType: streamState.providerType || currentModel?.providerType || undefined, placementMode: "anchored-generation", }; if (placeholderId) { const existingNode = useCanvasStore .getState() .nodes.find((node) => node.id === placeholderId); if (existingNode) { updateNode(placeholderId, { data: { ...((existingNode.data as any) || {}), src: "", status, title: label, prompt: label, metadata: { ...(((existingNode.data as any)?.metadata || {}) as Record), ...metadata, }, }, position, size: placeholderSize, }); focusCanvasOnNode({ position, size: placeholderSize }); return; } delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; } const nodeId = addNode({ type: "image", position, size: placeholderSize, data: { src: "", status, title: label, prompt: label, metadata, }, connections: anchorNode?.id ? [anchorNode.id] : [], }); canvasGenerationPlaceholderByChatIdRef.current[activeChatId] = nodeId; selectNodes([nodeId]); focusCanvasOnNode({ position, size: placeholderSize }); }, [ activeChatId, addNode, currentModel?.displayName, currentModel?.providerType, imageParams, canvasGenerationPlaceholderState, focusCanvasOnNode, removeCanvasGenerationPlaceholders, selectNodes, updateNode, ]); 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); } if ( message.role === "assistant" && isInternalCanvasImageGenerationFailureText(cleanedParts) ) { return acc; } 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 isPlainCanvasEmptyState = !isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && renderableVisibleMessages.length === 0 && !isLoading && !shouldShowImageLoading; const workspaceEmptyTitle = activeComposerMode === "agent" && !isPresetAgentSelection ? "开始 Auto Agent 对话" : hasAgentUploadSlots && attachments.length < agentRequiredUploadCount ? `请先补齐图片槽位(${attachments.length}/${agentRequiredUploadCount})` : "准备开始生成"; const workspaceEmptyDescription = activeComposerMode === "agent" && !isPresetAgentSelection ? hasSelectedAgentPreset ? "" : "" : hasAgentUploadSlots && attachments.length < agentRequiredUploadCount ? "从左侧 SKU / SPU 获取图片,或在下方继续上传替换后,再点击开始生成。" : "可在下方补充要求后点击开始生成,结果会显示在这里。"; const shouldShowAgentTargetLockCard = false; const isAgentTargetClarificationActive = shouldShowAgentTargetLockCard && activeAgentTargetLock?.stage === "clarify"; const shouldShowAgentProgressCard = activeComposerMode === "agent" && Boolean(activeAgentProgress); const agentTargetLockPreviewItems = useMemo(() => { if (!activeAgentTargetLock) return [] as AgentTargetLockRefPreview[]; const targets = Array.isArray(activeAgentTargetLock.targets) ? activeAgentTargetLock.targets : []; const candidates = Array.isArray(activeAgentTargetLock.candidates) ? activeAgentTargetLock.candidates : []; if (targets.length > 0) return targets.slice(0, 4); return candidates.slice(0, 4); }, [activeAgentTargetLock]); const agentTargetLockTitle = getAgentTargetLockTitle(activeAgentTargetLock); const agentTargetResolutionLabel = getAgentTargetResolutionLabel( activeAgentTargetLock?.resolutionMode ); const agentTargetLockPrimaryMessage = activeAgentTargetLock?.stage === "clarify" ? activeAgentTargetLock.clarificationMessage || activeAgentTargetLock.reason || "我需要先确认目标图或执行范围,避免误处理。" : activeAgentTargetLock?.reason || activeAgentTargetLock?.clarificationMessage || "系统正在根据当前上下文锁定目标图。"; const agentTargetLockSecondaryMessage = activeAgentTargetLock?.stage === "clarify" && activeAgentTargetLock.reason && activeAgentTargetLock.clarificationMessage && activeAgentTargetLock.reason !== activeAgentTargetLock.clarificationMessage ? activeAgentTargetLock.reason : ""; const agentProgressTitle = activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress); const agentProgressMessage = activeAgentProgress?.message?.trim() || "我正在根据当前上下文持续推进这一步。"; const agentProgressSteps = useMemo(() => { const currentStepIndex = Math.max( 0, AGENT_PROGRESS_STEPS.findIndex((step) => step.key === activeAgentProgress?.currentStep) ); return AGENT_PROGRESS_STEPS.map((step, index) => { let state: "upcoming" | "current" | "completed" | "failed" = "upcoming"; if (activeAgentProgress?.stage === "failed") { state = index < currentStepIndex ? "completed" : index === currentStepIndex ? "failed" : "upcoming"; } else if (activeAgentProgress?.stage === "completed") { state = "completed"; } else { state = index < currentStepIndex ? "completed" : index === currentStepIndex ? "current" : "upcoming"; } return { ...step, state, }; }); }, [activeAgentProgress]); const renderModelSelectContent = () => (
{selectableModels.map((model) => { const discountLabel = getImageModelCreditDiscountLabel( model.imageGenConfig ); return ( span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full", isVideoWorkspace ? "text-foreground focus:bg-background focus:text-foreground data-[state=checked]:bg-background data-[state=checked]:text-foreground" : "text-[#2f3640] focus:bg-[#f4f5f6] focus:text-[#1f2937] data-[state=checked]:bg-transparent data-[state=checked]:text-[#1f2937] dark:text-zinc-100 dark:focus:bg-white/10 dark:focus:text-white dark:data-[state=checked]:text-white", model.selectable === false && "opacity-55" )} > {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} {discountLabel ? ( {discountLabel} ) : null} {model.availabilityLabel ? ( {model.availabilityLabel} ) : null} {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, }; releaseSendGuard(activeChatId); setChatSessions((prevSessions) => [newSession, ...prevSessions]); setActiveChatId(newChatSessionId); toast.success("已新建聊天窗口"); }, [activeChatId, chatSessions.length, releaseSendGuard]); const switchToSession = useCallback((sessionId: string) => { if (!sessionId || sessionId === activeChatId) { setIsSessionMenuOpen(false); return; } forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); releaseSendGuard(activeChatId); setActiveChatId(sessionId); setIsSessionMenuOpen(false); toast.success("已切换聊天窗口"); }, [activeChatId, releaseSendGuard]); const activeSessionTitle = useMemo(() => { const current = chatSessions.find((session) => session.id === activeChatId); return String(current?.title || "").trim(); }, [chatSessions, activeChatId]); const displayCanvasName = useMemo(() => { if (activeSessionTitle) return activeSessionTitle; const fallbackTitle = String(projectTitle || "").trim(); return fallbackTitle || "未命名画布"; }, [activeSessionTitle, projectTitle]); return (
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */} {detailSlotPickerIndex !== null && (

正在从画布选择图片

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

)} {!isWorkspacePresentation ? (

{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 ( ); }) )}
)}
) : null} {/* 消息列表 */} {isDetailEmptyState ? (
) : (
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? ( isWorkspacePresentation ? (
{workspaceEmptyTitle}
{workspaceEmptyDescription ? (
{workspaceEmptyDescription}
) : null} {attachments.length > 0 ? (
{attachments.map((attachment, index) => (
{attachment.name { if (event.currentTarget.src !== attachment.url) { event.currentTarget.src = attachment.url; } }} />
))}
) : null}
) : (
{isVideoWorkspace ? (

视频画布助手

先选择一种起手方式,再把脚本、参考图、分镜或角色场景节点接进来,右侧会继续承接提示词和视频参数。

{videoWorkspaceQuickActions.map((action) => { const Icon = action.icon; return ( ); })}
{attachments.length > 0 ? (
{attachments.slice(0, 4).map((attachment, index) => (
{attachment.name { if (event.currentTarget.src !== attachment.url) { event.currentTarget.src = attachment.url; } }} />
))}
) : null}
) : (

Hi,{" "} {greetingUserName ? ( {greetingUserName} ) : null}{" "} {greetingPeriodText}

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

{canvasSkillTags.length > 0 ? (

预设快捷功能

{canvasSkillTags.slice(0, 4).map((tag) => { const isActive = selectedSkillTag?.id === tag.id; const TagIcon = getCanvasSkillIconComponent(tag.iconName); return ( ); })}
) : null}
)}
) ) : null} {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; if (isWorkspacePresentation) { if (message.role !== "assistant" || !hasAssistantMedia) { return null; } } 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) && !hasCanvasRenderableMarkdown(analysisSection?.analysis || "") && !typedAssistantSections[analysisTypingKey]; const shouldAnimatePlainText = shouldTypeAssistantMessage && !analysisSection && Boolean(plainText) && !hasCanvasRenderableMarkdown(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 )} {!isWorkspacePresentation && shouldShowCanvasBanner ? (
{ if (siteSettings?.bannerLink) { window.open(siteSettings.bannerLink, "_blank"); } }} >
{siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}
) : null} {/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
{isWorkspacePresentation && !isDetailGenerationMode ? (
补充要求
这里填写本次生图补充要求。图片槽位和生成结果都在当前工作台内完成,不再使用聊天对话方式。
) : 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="逗号分隔" />