"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 { usePathname, useRouter, useSearchParams } from "next/navigation"; 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, FileSpreadsheet, Paperclip } 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 { defaultCanvasAgentFeatureSettings, normalizeCanvasAgentFeatureSettings, type CanvasAgentFeatureSettings, } from "@/lib/canvas/agent-feature-flags"; import { resolveCanvasViewOperation, } from "@/lib/canvas/agent-view-operations"; import { interpolateCanvasAgentViewport, normalizeCanvasAgentViewAnimationDuration, } from "@/lib/canvas/agent-view-animation"; import { CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS, isCanvasAgentViewEditableElement, isCanvasAgentViewOperationTarget, shouldDeferCanvasAgentViewOperation, } from "@/lib/canvas/agent-view-interaction-guard"; import { getCanvasAgentOperationErrorMessage } from "@/lib/canvas/agent-operation-errors"; import { shouldBypassCanvasImagePointsPrecheckForAgentControl, shouldPreserveCanvasAgentSlotOnlyContext, } from "@/lib/canvas/agent-client-intent"; 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, filterCanvasMediaPayloadForUnhandledItems, getAgentProgressFallbackTitle, getAgentTargetLockTitle, getAgentTargetResolutionLabel, getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, type ImageGenerationStatusStreamPayload, isCanvasAgentViewOperationClientEnabled, shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; import { mergePendingCanvasAgentTargetNodeIds, resolvePendingCanvasAgentTargetNode, } from "@/components/canvas/chat-panel/canvas-agent-targets"; import { CANVAS_AGENT_ANNOTATION_EDIT_EVENT, CANVAS_AGENT_SLOT_FILL_EVENT, consumeCanvasAgentAnnotationEditRequest, consumeCanvasAgentSlotFillRequest, getPendingCanvasAgentAnnotationEditRequest, getPendingCanvasAgentSlotFillRequest, type CanvasAgentPendingRequestWindow, type PendingCanvasAgentAnnotationEditRequest, type PendingCanvasAgentSlotFillRequest, } from "@/lib/canvas/agent-pending-requests"; 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; canvasAgentFeatureSettings?: CanvasAgentFeatureSettings; } type CanvasComposerMode = "agent" | "image" | "video" | "detail" | "clawd"; const CLAW_MODE_LABEL = "Claw"; function getCanvasComposerModeSessionLabel(mode: CanvasComposerMode) { if (mode === "clawd") return CLAW_MODE_LABEL; if (mode === "video") return "视频"; if (mode === "detail") return "AI详情"; if (mode === "image") return "图像"; return "Agent"; } const CLAWD_TABLE_ACCEPT = ".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values"; const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]); const CLAWD_TABLE_MAX_FILES = 3; const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024; const CLAWD_PROCESSING_HINT = "已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。"; const CLAWD_SIMPLE_PROMPT_PATTERNS = [ /^(hi|hello|hey|你好|您好|哈喽|嗨|在吗|早上好|下午好|晚上好)[\s!.。!??~~]*$/i, /^(你是谁|你叫什么|你叫什么名字|你能做什么|你可以做什么|有什么功能|介绍一下自己|帮我介绍一下你)[\s!.。!??~~]*$/, ]; function getClawdTableFileExtension(fileName: string) { return String(fileName || "") .split(".") .pop() ?.toLowerCase() .trim() || ""; } function isClawdTableFile(file: File) { return CLAWD_TABLE_ALLOWED_EXTENSIONS.has( getClawdTableFileExtension(file.name) ); } function shouldShowClawdProcessingHint( message: string, hasTableFiles: boolean, hasImageAttachments: boolean ) { if (hasTableFiles || hasImageAttachments) { return true; } const normalizedMessage = String(message || "") .replace(/\u200b/g, " ") .replace(/\s+/g, " ") .trim(); if (!normalizedMessage) { return false; } return !CLAWD_SIMPLE_PROMPT_PATTERNS.some((pattern) => pattern.test(normalizedMessage) ); } function formatClawdTableFileSize(bytes: number) { if (!Number.isFinite(bytes) || bytes <= 0) { return "0KB"; } if (bytes >= 1024 * 1024) { return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)}MB`; } return `${Math.max(1, Math.round(bytes / 1024))}KB`; } function buildClawdTableDisplayText(message: string, files: File[]) { const prompt = message.trim() || "请分析上传的表格内容"; if (files.length === 0) { return prompt; } const lines = files.map( (file) => `- ${file.name}(${formatClawdTableFileSize(file.size)})` ); return `${prompt}\n\n已上传表格:\n${lines.join("\n")}`; } function buildClawdImageDisplayText(attachments: PickedObjectAttachment[]) { if (attachments.length === 0) { return ""; } const lines = attachments.map( (attachment, index) => `- ${normalizeChatAttachmentName(attachment.name) || `图片${index + 1}`}` ); return `已添加图片:\n${lines.join("\n")}`; } function buildClawdImageReferenceText(attachments: PickedObjectAttachment[]) { if (attachments.length === 0) { return ""; } const lines = attachments.map((attachment, index) => { const name = normalizeChatAttachmentName(attachment.name) || `图片${index + 1}`; return `- ${name}: ${attachment.url}`; }); return `用户还添加了以下图片作为参考,请按图片链接理解:\n${lines.join("\n")}`; } function getClawdDefaultPrompt(hasTables: boolean, hasImages: boolean) { if (hasTables && hasImages) { return "请分析上传的表格和图片内容"; } if (hasTables) { return "请分析上传的表格内容"; } if (hasImages) { return "请分析上传的图片内容"; } return "请分析"; } 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; const CANVAS_AGENT_VIEW_OPERATION_CLIENT_ENABLED = isCanvasAgentViewOperationClientEnabled( process.env.NEXT_PUBLIC_CANVAS_AGENT_VIEW_OPERATION_ENABLED ); 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; preserveMarkdownTables?: boolean; onActiveChange?: (typingKey: string, active: boolean) => void; onComplete?: (typingKey: string) => void; }; type MarkdownTableBlockRange = { start: number; end: number; }; function isLikelyMarkdownTableRow(line: string) { const trimmed = line.trim(); if (!trimmed || !trimmed.includes("|")) return false; const cells = trimmed .replace(/^\|/, "") .replace(/\|$/, "") .split("|"); return cells.length >= 2; } function isMarkdownTableSeparatorRow(line: string) { if (!isLikelyMarkdownTableRow(line)) return false; const cells = line .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((cell) => cell.trim().replace(/\s+/g, "")); return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); } function getMarkdownTableBlockRanges(content: string): MarkdownTableBlockRange[] { if (!content.includes("|")) return []; const rawLines = content.split("\n"); const lines = rawLines.map((line, index) => { const hasNewline = index < rawLines.length - 1; const text = hasNewline ? `${line}\n` : line; return { line: line.replace(/\r$/, ""), text, }; }); let offset = 0; const indexedLines = lines.map((line) => { const start = offset; offset += line.text.length; return { ...line, start, end: offset, }; }); const ranges: MarkdownTableBlockRange[] = []; for (let index = 0; index < indexedLines.length - 1; index += 1) { const header = indexedLines[index]; const separator = indexedLines[index + 1]; if ( !header || !separator || !isLikelyMarkdownTableRow(header.line) || !isMarkdownTableSeparatorRow(separator.line) ) { continue; } let endIndex = index + 2; while ( endIndex < indexedLines.length && isLikelyMarkdownTableRow(indexedLines[endIndex]?.line || "") ) { endIndex += 1; } ranges.push({ start: header.start, end: indexedLines[endIndex - 1]?.end || separator.end, }); index = endIndex - 1; } return ranges; } function advanceTypewriterCursor( contentLength: number, targetCursor: number, tableRanges: MarkdownTableBlockRange[] ) { const clampedCursor = Math.min(contentLength, targetCursor); for (const range of tableRanges) { if (clampedCursor >= range.start && clampedCursor < range.end) { return Math.min(contentLength, range.end); } } return clampedCursor; } function CanvasTypewriterCursor() { return (