perf: trim canvas first-open bundles
This commit is contained in:
parent
78f14edbfc
commit
103ca18031
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user