"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent } from "react"; import { useSession } from "next-auth/react"; import { 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, Copy, Square } 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, type UploadProgress, } from "@/lib/client/upload-with-retry"; import { downloadCanvasImageAsPng, type CanvasImageDownloadProgress, } from "@/lib/client/canvas-image-download"; 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 { resolveCanvasAgentRequestPolicy } from "@/lib/ai/runtime/canvas-agent-kernel"; 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 { resolveMultiImageReferenceContext } from "@/lib/canvas/multi-image-reference-context"; import { orderItemsBySelectionIds } from "@/lib/canvas/selection-order"; 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 { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; 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 { type AgentProgressStreamPayload, type AgentTargetLockRefPreview, type AgentTargetLockStreamPayload, buildAgentProgressNodeSteps, type CanvasOperationStreamPayload, type CanvasViewOperationStreamPayload, type CanvasMediaStreamPayload, filterCanvasMediaPayloadForUnhandledItems, getAgentProgressFallbackTitle, getAgentProgressStepLabel, 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, extractAssistantImageUrls, extractCanvasModelName, extractImageUrls, extractVideoUrls, formatMessageDateLabel, getAssistantImageNodeIdentityKey, getCanvasAssistantMessagesAfterTargetUser, getMessageDayKey, getMessageTimestamp, getNearestUserImageUrlKeys, getNearestUserImageUrls, hasCanvasImageMarkdown, hasCanvasLikelyGenerationErrorMessage, messageHasAssistantMedia, normalizeImageIdentityUrl, normalizeMediaUrl, sortCanvasMessagesByCreatedAt, stripCanvasModelRoutingLines, stripMediaMarkup, } 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"; const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试"; 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"; } function normalizeCanvasComposerSessionMode( value: unknown, fallback: CanvasComposerMode = "image" ): CanvasComposerMode { return value === "agent" || value === "image" || value === "video" || value === "detail" || value === "clawd" ? value : fallback; } function getCanvasChatSessionMode( session: Pick | null | undefined, fallback: CanvasComposerMode = "image" ) { if (!session) return fallback; if (session.mode) { return normalizeCanvasComposerSessionMode(session.mode, fallback); } return session.clawdSkillTagId ? "clawd" : fallback; } function isClawdChatMessage(message: unknown) { if (!message || typeof message !== "object") return false; const raw = message as Record; const metadata = raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata) ? raw.metadata : {}; const videoMetadata = raw.videoMetadata && typeof raw.videoMetadata === "object" && !Array.isArray(raw.videoMetadata) ? raw.videoMetadata : {}; return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd"; } 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_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000; const CLAWD_SUBMITTED_COMPOSER_RESTORE_GUARD_DELAYS_MS = [ 0, 80, 180, 360, 700, 1200, 2000, 3500, 6000, 10000, ]; const CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY = "ai-chatbot:clawd-submitted-composer"; 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 normalizeClawdComposerText(value: string) { return String(value || "") .replace(/\u200b/g, " ") .replace(/\s+/g, " ") .trim(); } function buildClawdSkillTagPrompt(tag: Pick | null) { if (!tag) return ""; const tagName = String(tag.name || "").trim() || "快捷标签"; const tagPrompt = String(tag.prompt || "").trim(); return [ `【快捷标签:${tagName}】`, tagPrompt || `用户选择了「${tagName}」快捷标签,请按该标签对应能力处理。`, ].join("\n"); } function reorderCanvasSkillTags( tags: CanvasSkillTag[], draggedId: string, targetId: string ) { if (!draggedId || !targetId || draggedId === targetId) return tags; const currentIndex = tags.findIndex((tag) => tag.id === draggedId); const targetIndex = tags.findIndex((tag) => tag.id === targetId); if (currentIndex < 0 || targetIndex < 0) return tags; const nextTags = [...tags]; const [draggedTag] = nextTags.splice(currentIndex, 1); if (!draggedTag) return tags; const nextTargetIndex = nextTags.findIndex((tag) => tag.id === targetId); nextTags.splice(nextTargetIndex < 0 ? targetIndex : nextTargetIndex, 0, draggedTag); return nextTags; } function applyCanvasSkillTagOrder(tags: CanvasSkillTag[], order: string[]) { if (tags.length < 2 || order.length === 0) return tags; const tagById = new Map(tags.map((tag) => [tag.id, tag])); const orderedTags = order .map((id) => tagById.get(id)) .filter((tag): tag is CanvasSkillTag => Boolean(tag)); const orderedIds = new Set(orderedTags.map((tag) => tag.id)); return [...orderedTags, ...tags.filter((tag) => !orderedIds.has(tag.id))]; } type ClawdComposerSubmissionSnapshot = { chatId: string; canvasId?: string; chatScope?: "canvas" | "agent"; expiresAt: number; startedAt: number; submittedTexts: string[]; }; type PendingClawdRequestRestoreSnapshot = { chatId: string; userMessageId: string; textContent: string; tableFiles: File[]; attachments: PickedObjectAttachment[]; skillTag: CanvasSkillTag | null; shouldRestoreSkillTag: boolean; }; function isClawdSubmittedComposerText( snapshot: ClawdComposerSubmissionSnapshot, value: string ) { const normalizedValue = normalizeClawdComposerText(value); if (!normalizedValue) return false; return snapshot.submittedTexts.some( (text) => normalizeClawdComposerText(text) === normalizedValue ); } function parseClawdComposerSubmissionSnapshot( value: unknown ): ClawdComposerSubmissionSnapshot | null { if (!value || typeof value !== "object") return null; const snapshot = value as Partial; const chatId = String(snapshot.chatId || "").trim(); const canvasId = String(snapshot.canvasId || "").trim(); const chatScope = snapshot.chatScope === "canvas" || snapshot.chatScope === "agent" ? snapshot.chatScope : undefined; const startedAt = Number(snapshot.startedAt); const expiresAt = Number(snapshot.expiresAt); const submittedTexts = Array.isArray(snapshot.submittedTexts) ? snapshot.submittedTexts .map((text) => normalizeClawdComposerText(String(text || ""))) .filter(Boolean) : []; if (!chatId || !Number.isFinite(startedAt) || !Number.isFinite(expiresAt)) { return null; } if (submittedTexts.length === 0) { return null; } return { chatId, canvasId: canvasId || undefined, chatScope, expiresAt, startedAt, submittedTexts, }; } function readStoredClawdComposerSubmissionSnapshot() { if (typeof window === "undefined") return null; try { const raw = window.sessionStorage.getItem( CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY ); if (!raw) return null; return parseClawdComposerSubmissionSnapshot(JSON.parse(raw)); } catch { return null; } } function writeStoredClawdComposerSubmissionSnapshot( snapshot: ClawdComposerSubmissionSnapshot | null ) { if (typeof window === "undefined") return; try { if (!snapshot) { window.sessionStorage.removeItem(CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY); return; } window.sessionStorage.setItem( CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY, JSON.stringify(snapshot) ); } catch { // Ignore session storage failures; the in-memory guard still handles normal tab switches. } } function shouldShowClawdProcessingHint( message: string, hasTableFiles: boolean, hasImageAttachments: boolean ) { if (hasTableFiles || hasImageAttachments) { return true; } const normalizedMessage = normalizeClawdComposerText(message); 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 套同风格方案", ]; const CANVAS_IMAGE_DIMENSION_TIMEOUT_MS = 3500; const DEFAULT_CANVAS_IMAGE_DIMENSIONS = { width: 300, height: 300, originalWidth: 0, originalHeight: 0, }; 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(); let settled = false; const resolveOnce = (dimensions: { width: number; height: number; originalWidth: number; originalHeight: number; }) => { if (settled) return; settled = true; window.clearTimeout(timeout); img.onload = null; img.onerror = null; resolve(dimensions); }; const timeout = window.setTimeout(() => { resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); }, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS); 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); } resolveOnce({ width, height, originalWidth, originalHeight }); }; img.onerror = () => { // 加载失败时使用默认尺寸 resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS); }; 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 = 15 * 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 getCanvasImageNodeUrl(node: CanvasNode | null | undefined) { if (!node || node.type !== "image") return ""; const data = (node.data || {}) as Record; return String( data.src || data.previewImage || data.imageUrl || data.renderArtifacts?.screenshotUrl || "" ).trim(); } 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 = getCanvasImageNodeUrl(node); 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; enablePdfDownload?: boolean; pdfFileName?: string; pdfSiteLogo?: string | null; pdfSiteName?: string | null; pdfTitle?: string; onActiveChange?: (typingKey: string, active: boolean) => void; onComplete?: (typingKey: string) => void; }; type OpenClawQuickAction = { label: string; message: string; intent?: string; description?: string; danger?: boolean; }; type OpenClawActionsBlock = { title?: string; actions: OpenClawQuickAction[]; }; type CanvasComposerUploadProgress = { title: string; message: string; current: number; total: number; fileName?: string; filePercent?: number; percent?: number; phase?: UploadProgress["phase"]; target?: "composer-reference" | "canvas" | "slot"; }; type CanvasUploadProgressOptions = { title: string; fileName?: string; fileIndex?: number; fileTotal?: number; target?: CanvasComposerUploadProgress["target"]; }; type ComposerUploadedImage = { imageUrl: string; name: string; contentType: string; assetId?: string; width?: number; height?: number; }; function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) { switch (phase) { case "preparing": return "正在准备上传"; case "direct": return "正在上传到云储存"; case "multipart": return "正在分片上传到云储存"; case "server": return "正在通过服务器上传"; case "finalizing": return "正在确认图片地址"; case "complete": return "上传完成"; default: return "正在上传图片"; } } function buildCanvasComposerUploadProgress( options: CanvasUploadProgressOptions, progress?: UploadProgress ): CanvasComposerUploadProgress { const total = Math.max(1, Math.round(options.fileTotal || 1)); const fileIndex = Math.min( total - 1, Math.max(0, Math.round(options.fileIndex || 0)) ); const current = fileIndex + 1; const filePercent = Math.max( 1, Math.min(100, Math.round(progress?.percent || 1)) ); const percent = total <= 1 ? filePercent : Math.max( 1, Math.min(100, Math.round(((fileIndex + filePercent / 100) / total) * 100)) ); const phaseLabel = getCanvasUploadProgressPhaseLabel(progress?.phase); return { title: options.title, message: total > 1 ? `${phaseLabel},第 ${current}/${total} 张,当前 ${filePercent}%` : `${phaseLabel},当前 ${filePercent}%`, current, total, fileName: options.fileName, filePercent, percent, phase: progress?.phase, target: options.target, }; } const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX = /```openclaw-actions\s*([\s\S]*?)```/gi; function normalizeOpenClawActionText(value: unknown, maxLength: number) { return String(value || "") .replace(/\s+/g, " ") .trim() .slice(0, maxLength); } function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return null; } const row = payload as Record; if ( row.openclaw_actions !== true && row.type !== "quick_replies" && !Array.isArray(row.actions) ) { return null; } const actions = Array.isArray(row.actions) ? row.actions .map((item): OpenClawQuickAction | null => { if (!item || typeof item !== "object" || Array.isArray(item)) { return null; } const action = item as Record; const label = normalizeOpenClawActionText(action.label, 24); const message = normalizeOpenClawActionText(action.message, 300); if (!label || !message) return null; const intent = normalizeOpenClawActionText(action.intent, 80); const description = normalizeOpenClawActionText(action.description, 120); return { label, message, intent: intent || undefined, description: description || undefined, danger: action.danger === true, }; }) .filter((item): item is OpenClawQuickAction => Boolean(item)) .slice(0, 10) : []; if (actions.length === 0) return null; return { title: normalizeOpenClawActionText(row.title, 40) || undefined, actions, }; } function extractOpenClawActionsFromMarkdown(content: string) { const blocks: OpenClawActionsBlock[] = []; const markdown = String(content || "").replace( OPENCLAW_ACTIONS_CODE_BLOCK_REGEX, (_match, rawJson) => { try { const parsed = JSON.parse(String(rawJson || "").trim()); const block = parseOpenClawActionsPayload(parsed); if (block) { blocks.push(block); } } catch { // Invalid action JSON should not break normal Markdown rendering. } return "\n"; } ); return { markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(), blocks, }; } function splitOpenClawMarkdownTableCells(line: string) { return String(line || "") .trim() .replace(/^\|/, "") .replace(/\|$/, "") .split(/(? cell .replace(/\\\|/g, "|") .replace(/\*\*(.*?)\*\*/g, "$1") .replace(/`([^`]+)`/g, "$1") .replace(/\s+/g, "") .trim() ); } function isOpenClawRoutingDiagnosticTable(tableLines: string[]) { if (tableLines.length < 3) return false; const headerCells = splitOpenClawMarkdownTableCells(tableLines[0] || ""); const hasDiagnosticHeader = headerCells.some((cell) => cell.includes("已尝试")) && headerCells.some((cell) => cell.includes("结果")); if (!hasDiagnosticHeader) return false; const bodyText = tableLines .slice(2) .map((line) => splitOpenClawMarkdownTableCells(line).join(" ")) .join(" "); return ( bodyText.includes("标准品类识别") || bodyText.includes("市场分析准备") || bodyText.includes("建议示例") ); } function stripOpenClawLowValueDiagnosticTables(content: string) { const normalized = String(content || "").replace(/\r\n/g, "\n"); if (!normalized.includes("|")) return normalized.trim(); const lines = normalized.split("\n"); const keptLines: string[] = []; let inCodeFence = false; for (let index = 0; index < lines.length; index += 1) { const line = lines[index] || ""; if (/^\s*```/.test(line)) { inCodeFence = !inCodeFence; keptLines.push(line); continue; } if ( !inCodeFence && index < lines.length - 1 && isLikelyMarkdownTableRow(line) && isMarkdownTableSeparatorRow(lines[index + 1] || "") ) { let endIndex = index + 2; while ( endIndex < lines.length && isLikelyMarkdownTableRow(lines[endIndex] || "") ) { endIndex += 1; } const tableLines = lines.slice(index, endIndex); if (isOpenClawRoutingDiagnosticTable(tableLines)) { index = endIndex - 1; continue; } } keptLines.push(line); } return keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } 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 (