perf: speed up canvas chat entry

This commit is contained in:
134 2026-07-10 18:09:49 +08:00
parent 988e1fefed
commit b50d3eb295
11 changed files with 285 additions and 16 deletions

View File

@ -192,6 +192,11 @@ import {
sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config";
import { cn, fetcher } from "@/lib/utils";
import {
loadCanvasChatPanelModule,
preloadCanvasChatPanel,
} from "@/components/canvas/canvas-chat-panel-loader";
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
import {
buildVideoWorkflowCanvasNode,
getVideoWorkflowNodeDefinition,
@ -311,7 +316,7 @@ const CanvasWorkspace = dynamic(
const CanvasChatPanel = dynamic(
() =>
import("@/components/canvas/CanvasChatPanel").then(
loadCanvasChatPanelModule().then(
(module) => module.CanvasChatPanel
),
{
@ -3278,16 +3283,14 @@ export default function CanvasEditorLegacyPage({
};
}, [checkCanvasAuthSession]);
useEffect(
() =>
scheduleCanvasIdleTask(() => {
void Promise.all([
import("@/components/canvas/CanvasChatPanel"),
import("@/components/canvas/CanvasFileListPanel"),
]);
}, 700),
[]
);
useEffect(() => {
preloadCanvasChatPanel();
preloadCanvasChatData(id);
return scheduleCanvasIdleTask(() => {
void import("@/components/canvas/CanvasFileListPanel");
}, 700);
}, [id]);
const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => {

View File

@ -5,8 +5,9 @@ import { useParams, useSearchParams } from "next/navigation";
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
import type { CanvasInitialProject } from "@/types/canvas";
import { loadCanvasEditorModule } from "./canvas-editor-loader";
const CanvasEditorLegacyPage = dynamic(() => import("./CanvasEditorLegacyPageClient"), {
const CanvasEditorLegacyPage = dynamic(loadCanvasEditorModule, {
loading: () => <CanvasRouteLoader label="正在加载画布..." />,
ssr: false,
});

View File

@ -0,0 +1,12 @@
type CanvasEditorModule = typeof import("./CanvasEditorLegacyPageClient");
let canvasEditorModulePromise: Promise<CanvasEditorModule> | null = null;
export function loadCanvasEditorModule() {
canvasEditorModulePromise ??= import("./CanvasEditorLegacyPageClient");
return canvasEditorModulePromise;
}
export function preloadCanvasEditor() {
void loadCanvasEditorModule();
}

View File

@ -13152,6 +13152,7 @@ export function CanvasChatPanel({
return (
<div
data-canvas-chat-panel="ready"
className={cn(
"relative flex h-full min-h-0 flex-col",
isWorkspacePresentation

View File

@ -0,0 +1,12 @@
type CanvasChatPanelModule = typeof import("./CanvasChatPanel");
let canvasChatPanelModulePromise: Promise<CanvasChatPanelModule> | null = null;
export function loadCanvasChatPanelModule() {
canvasChatPanelModulePromise ??= import("./CanvasChatPanel");
return canvasChatPanelModulePromise;
}
export function preloadCanvasChatPanel() {
void loadCanvasChatPanelModule();
}

View File

@ -58,6 +58,10 @@ import {
type CanvasLaunchAttachment,
getCanvasLaunchStorageKey,
} from "@/lib/canvas-launch";
import {
preloadCanvasWorkspace,
prepareCanvasNavigation,
} from "@/lib/client/canvas-workspace-preload";
import type { MembershipConfig } from "@/lib/membership/config";
import {
normalizeLovartHomeSidebarConfig,
@ -918,6 +922,28 @@ export function LovartHome({
setLoginDialogOpen(false);
}, [isAuthenticated]);
useEffect(() => {
if (!isAuthenticated || typeof window === "undefined") return;
const idleWindow = window as typeof window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (typeof idleWindow.requestIdleCallback === "function") {
const idleId = idleWindow.requestIdleCallback(preloadCanvasWorkspace, {
timeout: 1200,
});
return () => idleWindow.cancelIdleCallback?.(idleId);
}
const timer = window.setTimeout(preloadCanvasWorkspace, 450);
return () => window.clearTimeout(timer);
}, [isAuthenticated]);
useEffect(() => {
if (!loginDialogOpen) {
setIsLoggingIn(false);
@ -1365,6 +1391,7 @@ export function LovartHome({
if (!prompt.trim() && files.length === 0) return;
preloadCanvasWorkspace();
setIsSubmitting(true);
try {
const uploadedAttachments = await uploadSelectedFiles();
@ -1384,6 +1411,7 @@ export function LovartHome({
}
const canvasId: string = canvasResult.data.id;
prepareCanvasNavigation(canvasId);
if (typeof window !== "undefined") {
sessionStorage.setItem(
@ -1408,7 +1436,16 @@ export function LovartHome({
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
setFiles([]);
setPrompt("");
await mutateCanvas();
void mutateCanvas((current) => {
if (!current) return current;
return {
...current,
data: [
canvasResult.data as CanvasProject,
...(current.data || []).filter((item) => item.id !== canvasId),
],
};
}, false);
router.push(`/canvas/${canvasId}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败");
@ -1417,14 +1454,63 @@ export function LovartHome({
}
};
const openNewCanvas = () => {
const openNewCanvas = async () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
router.push("/canvas?new=1");
if (isSubmitting) return;
preloadCanvasWorkspace();
setIsSubmitting(true);
try {
const response = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "未命名画布" }),
});
const result = await response.json();
if (!response.ok || !result?.data?.id) {
throw new Error(result?.error || "创建项目失败");
}
const canvasId = String(result.data.id);
prepareCanvasNavigation(canvasId);
void mutateCanvas((current) => {
if (!current) return current;
return {
...current,
data: [
result.data as CanvasProject,
...(current.data || []).filter((item) => item.id !== canvasId),
],
};
}, false);
router.push(`/canvas/${canvasId}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败");
} finally {
setIsSubmitting(false);
}
};
const prepareCanvasProject = useCallback(
(projectId: string) => {
prepareCanvasNavigation(projectId);
router.prefetch(`/canvas/${projectId}`);
},
[router]
);
const openCanvasProject = useCallback(
(projectId: string) => {
prepareCanvasProject(projectId);
router.push(`/canvas/${projectId}`);
},
[prepareCanvasProject, router]
);
const handleDeleteProject = async (projectId: string, projectTitle?: string) => {
if (deletingProjectId === projectId) return;
@ -1863,7 +1949,9 @@ export function LovartHome({
isProjectsOnlyView={isProjectsOnlyView}
onCreateProject={openNewCanvas}
onDeleteProject={handleDeleteProject}
onOpenProject={(projectId) => router.push(`/canvas/${projectId}`)}
onOpenProject={openCanvasProject}
onPrepareCreate={preloadCanvasWorkspace}
onPrepareProject={prepareCanvasProject}
onViewAllProjects={openAllProjectsInPage}
projects={recentProjects}
projectSectionRef={projectSectionRef}

View File

@ -10,6 +10,8 @@ type LovartProjectsSectionProps = {
onCreateProject: () => void;
onDeleteProject: (projectId: string, projectTitle?: string) => void;
onOpenProject: (projectId: string) => void;
onPrepareCreate: () => void;
onPrepareProject: (projectId: string) => void;
onViewAllProjects: () => void;
projects: CanvasProject[];
projectSectionRef: Ref<HTMLElement>;
@ -22,6 +24,8 @@ export function LovartProjectsSection({
onCreateProject,
onDeleteProject,
onOpenProject,
onPrepareCreate,
onPrepareProject,
onViewAllProjects,
projects,
projectSectionRef,
@ -51,6 +55,8 @@ export function LovartProjectsSection({
<button
type="button"
onClick={onCreateProject}
onFocus={onPrepareCreate}
onPointerEnter={onPrepareCreate}
className="group block w-full cursor-pointer appearance-none bg-transparent p-0 text-left"
>
<div className="flex aspect-[16/9] items-center justify-center rounded-[8px] border border-dashed border-[#d8d8d8] bg-[#f4f4f4] transition-colors duration-150 group-hover:border-[#c8c8c8] group-hover:bg-[#f0f0f0] dark:border-white/15 dark:bg-[#18191d]">
@ -69,6 +75,8 @@ export function LovartProjectsSection({
<button
type="button"
onClick={() => onOpenProject(item.id)}
onFocus={() => onPrepareProject(item.id)}
onPointerEnter={() => onPrepareProject(item.id)}
className="block w-full cursor-pointer text-left"
>
<div className="aspect-[16/9] overflow-hidden rounded-[8px] border border-[#dddddd] bg-[#f4f4f4] dark:border-white/10 dark:bg-[#1b1c20]">

View File

@ -0,0 +1,42 @@
import { preload } from "swr";
import { fetcher } from "@/lib/utils";
const CANVAS_CHAT_DATA_KEYS = [
"/api/models?includeUnavailable=1",
"/api/site-settings?scope=canvas-chat",
"/api/canvas-skill-tags",
] as const;
async function nullableNoStoreFetcher(url: string) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return null;
return response.json();
}
function preloadWithoutBlocking(
key: string,
dataFetcher: (url: string) => Promise<unknown>
) {
void Promise.resolve(preload(key, dataFetcher)).catch(() => undefined);
}
export function preloadCanvasChatData(canvasId?: string | null) {
CANVAS_CHAT_DATA_KEYS.forEach((key) => {
preloadWithoutBlocking(key, fetcher);
});
preloadWithoutBlocking(
"/api/user/membership-summary",
nullableNoStoreFetcher
);
preloadWithoutBlocking("/api/user/image-stats", nullableNoStoreFetcher);
const normalizedCanvasId = String(canvasId || "").trim();
if (normalizedCanvasId) {
const params = new URLSearchParams({
chatId: normalizedCanvasId,
scope: "canvas",
});
preloadWithoutBlocking(`/api/messages?${params.toString()}`, fetcher);
}
}

View File

@ -0,0 +1,13 @@
import { preloadCanvasEditor } from "@/app/canvas/[id]/canvas-editor-loader";
import { preloadCanvasChatPanel } from "@/components/canvas/canvas-chat-panel-loader";
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
export function preloadCanvasWorkspace() {
preloadCanvasEditor();
preloadCanvasChatPanel();
}
export function prepareCanvasNavigation(canvasId?: string | null) {
preloadCanvasWorkspace();
preloadCanvasChatData(canvasId);
}

View File

@ -8,6 +8,7 @@ export default defineConfig({
/routes\/canvas-overlay-density\.test\.ts/,
/routes\/image-message-contracts\.test\.ts/,
/routes\/production-local-fallbacks\.test\.ts/,
/routes\/canvas-entry-performance\.test\.ts/,
],
fullyParallel: true,
forbidOnly: !!process.env.CI,

View File

@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
}
function readFunctionBlock(source: string, start: string, end: string) {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
expect(startIndex, `Missing ${start}`).toBeGreaterThanOrEqual(0);
expect(endIndex, `Missing ${end}`).toBeGreaterThan(startIndex);
return source.slice(startIndex, endIndex);
}
test.describe("home to canvas entry performance", () => {
test("navigates after project creation without waiting for a list refresh", () => {
const source = readSource("components/home/lovart-home.tsx");
const composerFlow = readFunctionBlock(
source,
"const createCanvasFromComposer = async () =>",
"const openNewCanvas = async () =>"
);
const blankCanvasFlow = readFunctionBlock(
source,
"const openNewCanvas = async () =>",
"const prepareCanvasProject = useCallback"
);
expect(composerFlow).toContain("prepareCanvasNavigation(canvasId)");
expect(composerFlow).toMatch(/router\.push\(`\/canvas\/\$\{canvasId\}`\)/);
expect(composerFlow).not.toContain("await mutateCanvas()");
expect(blankCanvasFlow).toContain('fetch("/api/canvas"');
expect(blankCanvasFlow).toMatch(/router\.push\(`\/canvas\/\$\{canvasId\}`\)/);
expect(blankCanvasFlow).not.toContain('router.push("/canvas?new=1")');
});
test("starts editor and chat downloads before the user opens the panel", () => {
const homeSource = readSource("components/home/lovart-home.tsx");
const editorSource = readSource(
"app/canvas/[id]/CanvasEditorLegacyPageClient.tsx"
);
const workspacePreloadSource = readSource(
"lib/client/canvas-workspace-preload.ts"
);
expect(homeSource).toContain("requestIdleCallback(preloadCanvasWorkspace");
expect(homeSource).toContain("timeout: 1200");
expect(homeSource).toContain(
"window.setTimeout(preloadCanvasWorkspace, 450)"
);
expect(workspacePreloadSource).toContain("preloadCanvasEditor()");
expect(workspacePreloadSource).toContain("preloadCanvasChatPanel()");
expect(editorSource).toContain("preloadCanvasChatPanel();");
expect(editorSource).toContain("preloadCanvasChatData(id);");
const entryWarmup = readFunctionBlock(
editorSource,
"useEffect(() => {\n preloadCanvasChatPanel();",
"const hydrateCanvasProject"
);
expect(entryWarmup).toContain(
'scheduleCanvasIdleTask(() => {\n void import("@/components/canvas/CanvasFileListPanel");'
);
expect(entryWarmup).not.toContain(
'import("@/components/canvas/CanvasChatPanel")'
);
});
test("reuses in-flight editor and chat module downloads", () => {
const editorLoader = readSource(
"app/canvas/[id]/canvas-editor-loader.ts"
);
const chatLoader = readSource(
"components/canvas/canvas-chat-panel-loader.ts"
);
expect(editorLoader).toContain(
'canvasEditorModulePromise ??= import("./CanvasEditorLegacyPageClient")'
);
expect(chatLoader).toContain(
'canvasChatPanelModulePromise ??= import("./CanvasChatPanel")'
);
});
});