"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo, lazy, Suspense, type ClipboardEvent as ReactClipboardEvent, type ComponentProps, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } 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, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole } 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, normalizeCanvasSkillCategories, normalizeCanvasSkillTags, type CanvasSkillCategory, type CanvasSkillTag, } from "@/lib/canvas-skill-tags"; import { applyCanvasSkillTagOrder, reorderCanvasSkillTags, } from "@/lib/canvas-skill-tag-order"; import { isClawdTransportInterruption, shouldRestoreClawdComposerAfterFailure, } from "@/lib/openclaw-chat/request-lifecycle"; import { getOpenClawClientErrorMessage, isOpenClawAuthRequiredError, OPENCLAW_AUTH_REQUIRED_MESSAGE, OPENCLAW_UPGRADE_RETRY_MESSAGE, } from "@/lib/openclaw-chat/errors"; 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 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 CanvasCapabilityCommandStreamPayload, 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 { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command"; 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, collectMessageImageAttachments, extractAssistantImageUrls, extractAudioUrls, extractCanvasModelName, extractImageUrls, extractVideoUrls, filterStandaloneAssistantImageUrls, formatMessageDateLabel, getAssistantImageNodeIdentityKey, getCanvasAssistantMessagesAfterTargetUser, getMessageDayKey, getMessageTimestamp, getNearestUserImageUrlKeys, getNearestUserImageUrls, hasCanvasImageMarkdown, hasCanvasLikelyGenerationErrorMessage, messageHasAssistantMedia, normalizeImageIdentityUrl, normalizeMediaUrl, sortCanvasMessagesByCreatedAt, stripCanvasModelRoutingLines, stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; import { clearClawdRequestState, startClawdRequestState, type ClawdRequestState, type ClawdRequestStateByChatId, updateClawdRequestState, } from "@/components/canvas/chat-panel/clawd-request-state"; import { buildOpenClawFileDisplayText, buildOpenClawFileReferenceText, buildOpenClawVideoDisplayText, buildOpenClawVideoReferenceText, getOpenClawChatFileContentType, getOpenClawChatVideoContentType, isOpenClawChatFolderFileAllowed, isOpenClawChatFile, isOpenClawChatFileAttachment, isOpenClawChatVideoAttachment, isOpenClawChatVideoFile, normalizeOpenClawChatMediaAttachments, OPENCLAW_CHAT_FILE_ACCEPT, OPENCLAW_CHAT_FILE_MAX_BYTES, OPENCLAW_CHAT_FILE_MAX_FILES, OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE, OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES, OPENCLAW_CHAT_FOLDER_MAX_FILES, OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES, OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE, OPENCLAW_CHAT_VIDEO_ACCEPT, OPENCLAW_CHAT_VIDEO_MAX_BYTES, OPENCLAW_CHAT_VIDEO_MAX_FILES, OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE, } from "@/lib/openclaw-chat/media-attachments"; type ResponseModule = typeof import("@/components/elements/response"); type ResponseProps = ComponentProps; const MarkdownResponse = lazy(async () => { const module = await import("@/components/elements/response"); return { default: module.Response }; }); function MarkdownResponseFallback({ children, className, }: Pick) { return (
{children}
); } function Response(props: ResponseProps) { return ( {props.children} } > ); } // 标签悬停预览信息 interface TagPreviewInfo { url: string; name: string; contentType?: 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; onRequireLogin?: (message?: string) => void; } type CanvasComposerMode = "agent" | "image" | "video" | "detail" | "clawd"; const CLAW_MODE_LABEL = "Claw"; const CANVAS_MODEL_SELECTOR_WIDTH_STORAGE_KEY = "ai-chatbot:canvas-model-selector-width"; const DEFAULT_CANVAS_MODEL_SELECTOR_WIDTH = 360; const MIN_CANVAS_MODEL_SELECTOR_WIDTH = 320; const MAX_CANVAS_MODEL_SELECTOR_WIDTH = 840; function clampCanvasModelSelectorWidth(value: number) { const viewportLimit = typeof window === "undefined" ? MAX_CANVAS_MODEL_SELECTOR_WIDTH : Math.max(MIN_CANVAS_MODEL_SELECTOR_WIDTH, window.innerWidth - 32); return Math.min( Math.max(Math.round(value), MIN_CANVAS_MODEL_SELECTOR_WIDTH), Math.min(MAX_CANVAS_MODEL_SELECTOR_WIDTH, viewportLimit), ); } function requestCanvasLogin( onRequireLogin: CanvasChatPanelProps["onRequireLogin"], message: string ) { if (onRequireLogin) { onRequireLogin(message); return; } if (typeof window === "undefined") { return; } const callbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; window.location.assign( `/login?callbackUrl=${encodeURIComponent(callbackUrl)}` ); } 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_IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff"; const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([ "jpg", "jpeg", "png", "webp", "heic", "heif", "avif", "tif", "tiff", ]); function isClawdSpreadsheetAttachment( url: string, contentType?: string | null ) { const normalizedType = String(contentType || "").toLowerCase(); return ( normalizedType.includes("spreadsheet") || normalizedType === "text/csv" || normalizedType === "text/tab-separated-values" || /\.(xlsx|csv|tsv)(?:$|[?#])/i.test(String(url || "")) ); } function getClawdFileBadge(name: string, contentType?: string) { if (String(contentType || "").includes("folder-manifest")) return "FOLDER"; const extension = name.split(".").pop()?.trim().toUpperCase() || ""; return extension && extension.length <= 6 ? extension : "FILE"; } 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_FOLDER_UPLOAD_STORAGE_PREFIX = "yedir:clawd-folder-upload:v1:"; const CLAWD_FOLDER_UPLOAD_STORAGE_TTL_MS = 14 * 24 * 60 * 60 * 1000; const CLAWD_FOLDER_UPLOAD_CONCURRENCY = 3; type ClawdFolderUploadedEntry = { path: string; signature: string; name: string; size: number; contentType: string; url: string | null; assetId?: string; }; type ClawdFolderUploadResumeState = { version: 1; fingerprint: string; updatedAt: number; entries: Record; }; function hashClawdUploadValue(value: string) { let hash = 0x811c9dc5; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(36); } function getClawdFolderFilePath(file: File) { return (file.webkitRelativePath || file.name).replace(/\\/g, "/"); } function getClawdFolderFileSignature(file: File, path: string) { return `${path}\u0000${file.size}\u0000${file.lastModified}`; } function getClawdFolderFingerprint(files: File[]) { return files .map((file) => getClawdFolderFileSignature(file, getClawdFolderFilePath(file)) ) .sort() .join("\u0001"); } function getClawdFolderUploadStorageKey(fingerprint: string) { return `${CLAWD_FOLDER_UPLOAD_STORAGE_PREFIX}${hashClawdUploadValue( fingerprint )}`; } function readClawdFolderUploadResumeState(files: File[]) { if (typeof window === "undefined") return null; const fingerprint = getClawdFolderFingerprint(files); const storageKey = getClawdFolderUploadStorageKey(fingerprint); try { const raw = window.localStorage.getItem(storageKey); if (!raw) return null; const parsed = JSON.parse(raw) as ClawdFolderUploadResumeState; if ( parsed.version !== 1 || parsed.fingerprint !== fingerprint || Date.now() - parsed.updatedAt > CLAWD_FOLDER_UPLOAD_STORAGE_TTL_MS ) { window.localStorage.removeItem(storageKey); return null; } return parsed; } catch { window.localStorage.removeItem(storageKey); return null; } } function writeClawdFolderUploadResumeState( files: File[], state: ClawdFolderUploadResumeState | null ) { if (typeof window === "undefined") return; const fingerprint = getClawdFolderFingerprint(files); const storageKey = getClawdFolderUploadStorageKey(fingerprint); try { if (!state) { window.localStorage.removeItem(storageKey); return; } window.localStorage.setItem( storageKey, JSON.stringify({ ...state, updatedAt: Date.now() }) ); } catch {} } const CLAWD_SIMPLE_PROMPT_PATTERNS = [ /^(hi|hello|hey|你好|您好|哈喽|嗨|在吗|早上好|下午好|晚上好)[\s!.。!??~~]*$/i, /^(你是谁|你叫什么|你叫什么名字|你能做什么|你可以做什么|有什么功能|介绍一下自己|帮我介绍一下你)[\s!.。!??~~]*$/, ]; function getClawdFileExtension(fileName: string) { return String(fileName || "") .split(".") .pop() ?.toLowerCase() .trim() || ""; } function isClawdImageFile(file: File) { return ( file.type.startsWith("image/") || CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdFileExtension(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"); } const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6; type CanvasSkillTagPointerDragState = { pointerId: number; tagId: string; startX: number; startY: number; moved: boolean; }; 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; }; type ClawdProgressEvent = { key: string; eventId?: number; sequence?: number; stepCode: string; stepState: string; message: string; currentOrderId: string; completed?: number; total?: number; progressPercent?: number; }; type ClawdDurableTaskStatus = | "queued" | "running" | "completed" | "failed" | "canceled"; type ClawdDurableTaskSnapshot = { chatId: string; userMessageId: string; assistantMessageId: string; status: ClawdDurableTaskStatus; processingHint: string; progressEvents: Record[]; skillTagId: string | null; skillTagName: string | null; requestId: string | null; error: string | null; createdAt: number; updatedAt: number; userMessage: { text: string; attachments: Array<{ name?: string; url?: string; contentType?: string; }>; }; }; type ClawdDurableTaskResponse = { task: ClawdDurableTaskSnapshot | null; }; class ClawdTaskStatusError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = "ClawdTaskStatusError"; this.status = status; } } function isPendingClawdDurableTask(status: ClawdDurableTaskStatus) { return status === "queued" || status === "running"; } async function fetchClawdDurableTask(params: { rawChatId: string; userMessageId?: string; signal?: AbortSignal; }) { const query = new URLSearchParams({ id: params.rawChatId }); if (params.userMessageId) { query.set("userMessageId", params.userMessageId); } const response = await fetch( `/api/canvas/clawd-chat/status?${query.toString()}`, { cache: "no-store", signal: params.signal, } ); if (response.status === 404) return null; const payload = (await response.json().catch(() => null)) as | ClawdDurableTaskResponse | null; if (!response.ok) { throw new ClawdTaskStatusError( payload && "error" in payload ? String((payload as { error?: unknown }).error || "") : `Claw task status failed: ${response.status}`, response.status ); } return payload?.task || null; } async function waitForClawdDurableTask(params: { rawChatId: string; userMessageId: string; signal?: AbortSignal; onUpdate: (task: ClawdDurableTaskSnapshot) => void; }) { let consecutiveFailures = 0; while (!params.signal?.aborted) { let task: ClawdDurableTaskSnapshot | null = null; try { task = await fetchClawdDurableTask({ rawChatId: params.rawChatId, userMessageId: params.userMessageId, signal: params.signal, }); consecutiveFailures = 0; } catch (error) { if (params.signal?.aborted) throw error; if ( error instanceof ClawdTaskStatusError && [400, 401, 403].includes(error.status) ) { throw error; } consecutiveFailures += 1; } if (!task && consecutiveFailures === 0) return null; if (!task) { await new Promise((resolve) => window.setTimeout(resolve, 1600)); continue; } params.onUpdate(task); if (!isPendingClawdDurableTask(task.status)) return task; await new Promise((resolve, reject) => { const cleanup = () => { params.signal?.removeEventListener("abort", handleAbort); }; const timer = window.setTimeout(() => { cleanup(); resolve(); }, 1600); const handleAbort = () => { window.clearTimeout(timer); cleanup(); reject(new DOMException("Claw task polling canceled", "AbortError")); }; params.signal?.addEventListener("abort", handleAbort, { once: true }); }); } throw new DOMException("Claw task polling canceled", "AbortError"); } function normalizeClawdProgressEvent( progress: Record ): ClawdProgressEvent | null { if (progress.heartbeat === true) return null; const message = String( progress.message || progress.display || "" ).trim(); if (!message) return null; const eventId = Number(progress.event_id); const sequence = Number(progress.sequence); const stepCode = String(progress.step_code || "").trim(); const stepState = String(progress.step_state || "").trim(); const currentOrderId = String(progress.current_order_id || "").trim(); const completed = Number(progress.completed); const total = Number(progress.total); const progressPercent = Number(progress.progress_percent); const normalizedEventId = Number.isFinite(eventId) && eventId > 0 ? eventId : undefined; const normalizedSequence = Number.isFinite(sequence) && sequence > 0 ? sequence : undefined; return { key: normalizedEventId != null ? `event-${normalizedEventId}` : `sequence-${normalizedSequence || 0}-${stepCode}-${message}`, eventId: normalizedEventId, sequence: normalizedSequence, stepCode, stepState, message, currentOrderId, completed: Number.isFinite(completed) && completed >= 0 ? completed : undefined, total: progress.total != null && Number.isFinite(total) && total > 0 ? total : undefined, progressPercent: progress.progress_percent != null && Number.isFinite(progressPercent) && progressPercent >= 0 && progressPercent <= 100 ? Math.round(progressPercent) : undefined, }; } 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, hasVideoAttachments: boolean, hasFolderAttachments: boolean ) { if ( hasTableFiles || hasImageAttachments || hasVideoAttachments || hasFolderAttachments ) { 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")}`; } type ClawdSpreadsheetAttachment = { name: string; size?: number; contentType?: string; sheetCount?: number; rowCount?: number; truncated?: boolean; }; function parseClawdSpreadsheetAttachmentsFromText( message: ChatMessage ): ClawdSpreadsheetAttachment[] { const text = (message.parts || []) .filter((part: any) => part?.type === "text" && part?.text) .map((part: any) => String(part.text)) .join("\n"); const summaryMatch = text.match( /(?:^|\n)已上传表格:\n((?:- [^\n]+(?:\n|$))+)/m ); if (!summaryMatch?.[1]) { return []; } const attachments: ClawdSpreadsheetAttachment[] = []; for (const line of summaryMatch[1].trim().split("\n")) { const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/); const name = String(lineMatch?.[1] || "").trim(); const details = String(lineMatch?.[2] || ""); if (!name) continue; const sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i); const sheetCountMatch = details.match(/(\d+)\s*个工作表/); const rowCountMatch = details.match(/(\d+)\s*行/); const sizeValue = Number(sizeMatch?.[1]); const sizeUnit = String(sizeMatch?.[2] || "").toUpperCase(); attachments.push({ name, size: Number.isFinite(sizeValue) && sizeValue > 0 ? Math.round( sizeValue * (sizeUnit === "MB" ? 1024 * 1024 : 1024) ) : undefined, sheetCount: sheetCountMatch ? Number(sheetCountMatch[1]) : undefined, rowCount: rowCountMatch ? Number(rowCountMatch[1]) : undefined, truncated: details.includes("已截断"), }); } return attachments; } function getClawdSpreadsheetAttachments( message: ChatMessage ): ClawdSpreadsheetAttachment[] { const metadata = (message as any)?.metadata && typeof (message as any).metadata === "object" ? (message as any).metadata : {}; const videoMetadata = (message as any)?.videoMetadata && typeof (message as any).videoMetadata === "object" ? (message as any).videoMetadata : {}; const rawAttachments = Array.isArray(metadata.spreadsheetAttachments) ? metadata.spreadsheetAttachments : Array.isArray(videoMetadata.spreadsheetAttachments) ? videoMetadata.spreadsheetAttachments : []; if (rawAttachments.length === 0) { return parseClawdSpreadsheetAttachmentsFromText(message); } return rawAttachments .map((attachment: any) => { const name = String( attachment?.name || attachment?.fileName || "" ).trim(); if (!name) return null; return { name, size: Number.isFinite(Number(attachment?.size)) && Number(attachment.size) > 0 ? Number(attachment.size) : undefined, contentType: String( attachment?.contentType || attachment?.content_type || "" ).trim() || undefined, sheetCount: Number.isFinite(Number(attachment?.sheetCount)) && Number(attachment.sheetCount) >= 0 ? Number(attachment.sheetCount) : undefined, rowCount: Number.isFinite(Number(attachment?.rowCount)) && Number(attachment.rowCount) >= 0 ? Number(attachment.rowCount) : undefined, truncated: attachment?.truncated === true, } satisfies ClawdSpreadsheetAttachment; }) .filter( ( attachment: ClawdSpreadsheetAttachment | null ): attachment is ClawdSpreadsheetAttachment => Boolean(attachment) ); } function stripClawdSpreadsheetSummary(text: string) { return text .replace( /(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g, "\n" ) .replace(/\n{3,}/g, "\n\n") .trim(); } 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, hasVideos: boolean, hasFolders: boolean ) { const mediaNames = [ hasTables ? "表格" : "", hasImages ? "图片" : "", hasVideos ? "视频" : "", hasFolders ? "文件夹" : "", ].filter(Boolean); return mediaNames.length > 0 ? `请分析上传的${mediaNames.join("、")}内容` : "请分析"; } 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 OpenClawSecureField = { name: "username" | "password" | "verification_code"; label: string; inputType: "text" | "password"; inputMode?: "text" | "numeric"; autocomplete?: string; maxLength: number; }; type OpenClawSecureForm = { formType: "taobao_credentials" | "taobao_verification"; title: string; submitMessage: string; fields: OpenClawSecureField[]; }; type OpenClawSensitiveInput = | { type: "taobao_credentials"; username: string; password: string } | { type: "taobao_verification"; verification_code: string }; 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 "resuming": 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; const OPENCLAW_FORM_CODE_BLOCK_REGEX = /```openclaw-form\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 parseOpenClawSecureFormPayload(payload: unknown): OpenClawSecureForm | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const row = payload as Record; const formType = normalizeOpenClawActionText(row.form_type, 40); if ( row.openclaw_form !== true || row.type !== "secure_form" || row.persist !== false || !["taobao_credentials", "taobao_verification"].includes(formType) || !Array.isArray(row.fields) ) { return null; } const allowedNames = formType === "taobao_credentials" ? new Set(["username", "password"]) : new Set(["verification_code"]); const fields = row.fields .map((item): OpenClawSecureField | null => { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const field = item as Record; const name = normalizeOpenClawActionText(field.name, 40); if (!allowedNames.has(name)) return null; const inputType = field.input_type === "password" ? "password" : "text"; return { name: name as OpenClawSecureField["name"], label: normalizeOpenClawActionText(field.label, 40) || name, inputType, inputMode: field.input_mode === "numeric" ? "numeric" : "text", autocomplete: normalizeOpenClawActionText(field.autocomplete, 40) || undefined, maxLength: Math.max(4, Math.min(256, Number(field.max_length) || 160)), }; }) .filter((item): item is OpenClawSecureField => Boolean(item)); if (fields.length !== allowedNames.size || new Set(fields.map((field) => field.name)).size !== fields.length) { return null; } return { formType: formType as OpenClawSecureForm["formType"], title: normalizeOpenClawActionText(row.title, 60) || "安全登录", submitMessage: normalizeOpenClawActionText(row.submit_message, 120), fields, }; } function extractOpenClawActionsFromMarkdown(content: string) { const blocks: OpenClawActionsBlock[] = []; const forms: OpenClawSecureForm[] = []; const withoutActions = 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"; } ); const markdown = withoutActions.replace( OPENCLAW_FORM_CODE_BLOCK_REGEX, (_match, rawJson) => { try { const parsed = JSON.parse(String(rawJson || "").trim()); const form = parseOpenClawSecureFormPayload(parsed); if (form) forms.push(form); } catch { // Invalid secure-form JSON should not break normal Markdown rendering. } return "\n"; } ); return { markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(), blocks, forms, }; } 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 (