perf: speed up canvas chat entry
This commit is contained in:
parent
988e1fefed
commit
b50d3eb295
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
12
app/canvas/[id]/canvas-editor-loader.ts
Normal file
12
app/canvas/[id]/canvas-editor-loader.ts
Normal 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();
|
||||
}
|
||||
@ -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
|
||||
|
||||
12
components/canvas/canvas-chat-panel-loader.ts
Normal file
12
components/canvas/canvas-chat-panel-loader.ts
Normal 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();
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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]">
|
||||
|
||||
42
lib/client/canvas-chat-preload.ts
Normal file
42
lib/client/canvas-chat-preload.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
lib/client/canvas-workspace-preload.ts
Normal file
13
lib/client/canvas-workspace-preload.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
88
tests/routes/canvas-entry-performance.test.ts
Normal file
88
tests/routes/canvas-entry-performance.test.ts
Normal 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")'
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user