diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 9b77bde48..685c7e456 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2,7 +2,7 @@ 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 { useEffect, useRef, useState, useCallback, useMemo, lazy, Suspense, type ClipboardEvent as ReactClipboardEvent, type ComponentProps, 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"; @@ -135,7 +135,6 @@ import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-p 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"; @@ -283,6 +282,39 @@ import { stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; +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; diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 31bd94111..91beecfda 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -6,7 +6,6 @@ import { Rnd } from "react-rnd"; import { motion } from "framer-motion"; import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas"; -import { ImageNode } from "./nodes/ImageNode"; import { AgentSlotNode } from "./nodes/AgentSlotNode"; import { TextNode } from "./nodes/TextNode"; import { UiScreenNode } from "./nodes/UiScreenNode"; @@ -67,12 +66,19 @@ import { } from "@/lib/canvas/agent-pending-requests"; import { inferOriginalImageUrl } from "@/lib/image-url"; +type ImageNodeModule = typeof import("./nodes/ImageNode"); type Model3DNodeModule = typeof import("./nodes/Model3DNode"); type World3DNodeModule = typeof import("./nodes/World3DNode"); +let imageNodeModulePromise: Promise | null = null; let model3DNodeModulePromise: Promise | null = null; let world3DNodeModulePromise: Promise | null = null; +function loadImageNodeModule() { + imageNodeModulePromise ??= import("./nodes/ImageNode"); + return imageNodeModulePromise; +} + function loadModel3DNodeModule() { model3DNodeModulePromise ??= import("./nodes/Model3DNode"); return model3DNodeModulePromise; @@ -88,6 +94,18 @@ function preloadCanvas3DNodeRenderers() { void loadWorld3DNodeModule(); } +function preloadCanvasImageNodeRenderer() { + void loadImageNodeModule(); +} + +function CanvasImageNodeLoading() { + return ( +
+ 正在加载图片节点 +
+ ); +} + function Canvas3DNodeLoading() { return (
@@ -96,6 +114,13 @@ function Canvas3DNodeLoading() { ); } +const ImageNode = dynamic( + () => loadImageNodeModule().then((module) => module.ImageNode), + { + loading: () => , + ssr: false, + } +); const Model3DNode = dynamic( () => loadModel3DNodeModule().then((module) => module.Model3DNode), { @@ -395,6 +420,10 @@ export function CanvasWorkspace({ const tool = useCanvasStore(canvasSelectors.tool); const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId); const setViewport = useCanvasStore((state) => state.setViewport); + const hasImageNodes = useMemo( + () => nodes.some((node) => node.type === "image"), + [nodes] + ); const has3DNodes = useMemo( () => nodes.some((node) => node.type === "model3d" || node.type === "world3d"), [nodes] @@ -417,6 +446,49 @@ export function CanvasWorkspace({ viewportRef.current = viewport; }, [viewport]); + React.useEffect(() => { + if (hasImageNodes) { + preloadCanvasImageNodeRenderer(); + return; + } + + if (typeof window === "undefined") return; + const connection = ( + navigator as Navigator & { + connection?: { effectiveType?: string; saveData?: boolean }; + } + ).connection; + if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) { + return; + } + + const idleWindow = window as typeof window & { + cancelIdleCallback?: (handle: number) => void; + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions + ) => number; + }; + let idleId: number | null = null; + const warmupTimer = window.setTimeout(() => { + if (document.visibilityState !== "visible") return; + if (typeof idleWindow.requestIdleCallback === "function") { + idleId = idleWindow.requestIdleCallback(preloadCanvasImageNodeRenderer, { + timeout: 12000, + }); + return; + } + preloadCanvasImageNodeRenderer(); + }, 9000); + + return () => { + window.clearTimeout(warmupTimer); + if (idleId !== null) { + idleWindow.cancelIdleCallback?.(idleId); + } + }; + }, [hasImageNodes]); + React.useEffect(() => { if (has3DNodes) { preloadCanvas3DNodeRenderers(); diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index e2b74662e..02b0de1de 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -1,5 +1,6 @@ "use client"; +import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; @@ -73,7 +74,18 @@ import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-compose import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section"; import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar"; -type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator; +const ImageAnnotatorLazy = dynamic( + () => import("@/components/image-annotator").then((module) => module.ImageAnnotator), + { + loading: () => ( +
+ + 正在加载图片编辑器... +
+ ), + ssr: false, + } +); type HomeUser = { id: string; @@ -457,8 +469,6 @@ export function LovartHome({ const [showDingTalkOrgMenu, setShowDingTalkOrgMenu] = useState(false); const [showImageEditor, setShowImageEditor] = useState(false); const [editorImageUrl, setEditorImageUrl] = useState(""); - const [ImageAnnotatorLazy, setImageAnnotatorLazy] = - useState(null); const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false); const [currentTutorialVideo, setCurrentTutorialVideo] = useState(null); const [contactDialogOpen, setContactDialogOpen] = useState(false); @@ -1088,43 +1098,6 @@ export function LovartHome({ filesRef.current = files; }, [files]); - useEffect(() => { - if (ImageAnnotatorLazy) { - return; - } - - let disposed = false; - const preloadImageAnnotator = () => void import("@/components/image-annotator").then((module) => { - if (!disposed) { - setImageAnnotatorLazy(() => module.ImageAnnotator); - } - }); - - const idleWindow = window as Window & { - cancelIdleCallback?: (handle: number) => void; - requestIdleCallback?: ( - callback: IdleRequestCallback, - options?: IdleRequestOptions - ) => number; - }; - if (typeof idleWindow.requestIdleCallback === "function") { - const idleId = idleWindow.requestIdleCallback(preloadImageAnnotator, { - timeout: 900, - }); - return () => { - disposed = true; - idleWindow.cancelIdleCallback?.(idleId); - }; - } - - const timer = window.setTimeout(preloadImageAnnotator, 180); - - return () => { - disposed = true; - window.clearTimeout(timer); - }; - }, [ImageAnnotatorLazy]); - useEffect(() => { return () => { filesRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl)); @@ -1980,7 +1953,7 @@ export function LovartHome({ - {showImageEditor && ImageAnnotatorLazy ? ( + {showImageEditor ? ( - ) : showImageEditor ? ( -
- - 正在加载图片编辑器... -
) : null} import("./angle-edit-dialog").then((module) => module.AngleEditDialog), + { + loading: () => null, + ssr: false, + } +); + +const SceneFusionView = dynamic( + () => import("./scene-fusion-view").then((module) => module.SceneFusionView), + { + loading: () => null, + ssr: false, + } +); + type Tool = "pen" | "arrow" | "circle" | "text"; const logger = createClientLogger("image-annotator"); diff --git a/components/image-preview.tsx b/components/image-preview.tsx index 1750c9332..cdd72044e 100644 --- a/components/image-preview.tsx +++ b/components/image-preview.tsx @@ -1,12 +1,27 @@ "use client"; +import dynamic from "next/dynamic"; import React, { useState, useEffect } from "react"; import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react"; import { toast } from "sonner"; -import { ImageAnnotator } from "./image-annotator"; -import { AngleEditDialog } from "./angle-edit-dialog"; import { buildImageProxyUrl } from "@/lib/image-delivery"; +const ImageAnnotator = dynamic( + () => import("./image-annotator").then((module) => module.ImageAnnotator), + { + loading: () => null, + ssr: false, + } +); + +const AngleEditDialog = dynamic( + () => import("./angle-edit-dialog").then((module) => module.AngleEditDialog), + { + loading: () => null, + ssr: false, + } +); + function triggerBrowserDownload( url: string, filename?: string, diff --git a/tests/routes/canvas-entry-performance.test.ts b/tests/routes/canvas-entry-performance.test.ts index 763703480..166247108 100644 --- a/tests/routes/canvas-entry-performance.test.ts +++ b/tests/routes/canvas-entry-performance.test.ts @@ -106,4 +106,68 @@ test.describe("home to canvas entry performance", () => { expect(workspaceSource).toContain("timeout: 15000"); expect(workspaceSource).toContain("}, 12000)"); }); + + test("keeps image editing tools out of the home and chat first-open path", () => { + const homeSource = readSource("components/home/lovart-home.tsx"); + const imagePreviewSource = readSource("components/image-preview.tsx"); + const imageAnnotatorSource = readSource("components/image-annotator.tsx"); + const workspaceSource = readSource("components/canvas/CanvasWorkspace.tsx"); + + expect(homeSource).not.toContain( + 'requestIdleCallback(preloadImageAnnotator' + ); + expect(homeSource).not.toContain( + 'window.setTimeout(preloadImageAnnotator' + ); + expect(homeSource).toContain( + 'import("@/components/image-annotator").then((module) => module.ImageAnnotator)' + ); + + expect(imagePreviewSource).not.toContain( + 'import { ImageAnnotator } from "./image-annotator"' + ); + expect(imagePreviewSource).not.toContain( + 'import { AngleEditDialog } from "./angle-edit-dialog"' + ); + expect(imagePreviewSource).toContain( + 'import("./image-annotator").then((module) => module.ImageAnnotator)' + ); + expect(imagePreviewSource).toContain( + 'import("./angle-edit-dialog").then((module) => module.AngleEditDialog)' + ); + + expect(imageAnnotatorSource).not.toContain( + 'import { AngleEditDialog } from "./angle-edit-dialog"' + ); + expect(imageAnnotatorSource).not.toContain( + 'import { SceneFusionView } from "./scene-fusion-view"' + ); + expect(imageAnnotatorSource).toContain( + 'import("./angle-edit-dialog").then((module) => module.AngleEditDialog)' + ); + expect(imageAnnotatorSource).toContain( + 'import("./scene-fusion-view").then((module) => module.SceneFusionView)' + ); + + expect(workspaceSource).not.toContain( + 'import { ImageNode } from "./nodes/ImageNode"' + ); + expect(workspaceSource).toContain( + 'imageNodeModulePromise ??= import("./nodes/ImageNode")' + ); + expect(workspaceSource).toContain("preloadCanvasImageNodeRenderer"); + }); + + test("loads markdown response rendering only when message content needs it", () => { + const chatPanelSource = readSource("components/canvas/CanvasChatPanel.tsx"); + + expect(chatPanelSource).not.toContain( + 'import { Response } from "@/components/elements/response"' + ); + expect(chatPanelSource).toContain( + 'await import("@/components/elements/response")' + ); + expect(chatPanelSource).toContain("function MarkdownResponseFallback"); + expect(chatPanelSource).toContain(""); + }); });