perf: trim canvas first-open bundles

This commit is contained in:
134 2026-07-10 18:52:41 +08:00
parent 78f14edbfc
commit 103ca18031
6 changed files with 219 additions and 53 deletions

View File

@ -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<ResponseModule["Response"]>;
const MarkdownResponse = lazy(async () => {
const module = await import("@/components/elements/response");
return { default: module.Response };
});
function MarkdownResponseFallback({
children,
className,
}: Pick<ResponseProps, "children" | "className">) {
return (
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
{children}
</div>
);
}
function Response(props: ResponseProps) {
return (
<Suspense
fallback={
<MarkdownResponseFallback className={props.className}>
{props.children}
</MarkdownResponseFallback>
}
>
<MarkdownResponse {...props} />
</Suspense>
);
}
// 标签悬停预览信息
interface TagPreviewInfo {
url: string;

View File

@ -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<ImageNodeModule> | null = null;
let model3DNodeModulePromise: Promise<Model3DNodeModule> | null = null;
let world3DNodeModulePromise: Promise<World3DNodeModule> | 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 (
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f8f9fb] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
正在加载图片节点
</div>
);
}
function Canvas3DNodeLoading() {
return (
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f6f7f9] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
@ -96,6 +114,13 @@ function Canvas3DNodeLoading() {
);
}
const ImageNode = dynamic(
() => loadImageNodeModule().then((module) => module.ImageNode),
{
loading: () => <CanvasImageNodeLoading />,
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();

View File

@ -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: () => (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 text-white backdrop-blur-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
正在加载图片编辑器...
</div>
),
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<ImageAnnotatorComponent | null>(null);
const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false);
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(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({
<ArrowUp className="h-4 w-4" />
</button>
{showImageEditor && ImageAnnotatorLazy ? (
{showImageEditor ? (
<ImageAnnotatorLazy
imageUrl={editorImageUrl}
onConfirm={handleImageEditorConfirm}
@ -1988,11 +1961,6 @@ export function LovartHome({
onCancel={handleImageEditorCancel}
onHomeClick={handleImageEditorCancel}
/>
) : showImageEditor ? (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 text-white backdrop-blur-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
正在加载图片编辑器...
</div>
) : null}
<Dialog

View File

@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import {
Check,
ChevronDown,
@ -57,12 +58,26 @@ import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createClientLogger } from "@/lib/observability/client-logger";
import { cn } from "@/lib/utils";
import { AngleEditDialog } from "./angle-edit-dialog";
import { SceneFusionView } from "./scene-fusion-view";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Slider } from "./ui/slider";
const AngleEditDialog = dynamic(
() => 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");

View File

@ -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,

View File

@ -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("<MarkdownResponse {...props} />");
});
});