From 68eab912d867101fad11e77bd74a9e33609f9e37 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 20 May 2026 18:10:43 +0800 Subject: [PATCH] perf: optimize agent style rendering --- .../client-performance/agent-style/route.ts | 198 ++++++++++ .../agents/AgentGenerationWorkspaceDarkV2.tsx | 161 +++++--- components/agents/AgentPreviewImage.tsx | 131 +++++++ .../agents/AgentWorkflowMaterialPicker.tsx | 362 +++++++++++++----- .../use-agent-style-performance-monitor.ts | 241 ++++++++++++ components/image-delivery-optimizer.tsx | 112 +++++- lib/client/yield-to-browser.ts | 37 ++ .../0082_add_client_performance_events.sql | 34 ++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 27 ++ 10 files changed, 1127 insertions(+), 183 deletions(-) create mode 100644 app/api/client-performance/agent-style/route.ts create mode 100644 components/agents/AgentPreviewImage.tsx create mode 100644 components/agents/use-agent-style-performance-monitor.ts create mode 100644 lib/client/yield-to-browser.ts create mode 100644 lib/db/migrations/0082_add_client_performance_events.sql diff --git a/app/api/client-performance/agent-style/route.ts b/app/api/client-performance/agent-style/route.ts new file mode 100644 index 000000000..798477532 --- /dev/null +++ b/app/api/client-performance/agent-style/route.ts @@ -0,0 +1,198 @@ +import { sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { clientPerformanceEvent } from "@/lib/db/schema"; + +export const runtime = "nodejs"; +export const maxDuration = 10; + +const SLOW_EVENT_MIN_DURATION_MS = 120; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_EVENTS = 20; +const TEXT_MAX_LENGTH = 256; + +const allowedSections = [ + "brand", + "style", + "mode", + "materials", + "generate", + "references", + "results", + "unknown", +] as const; + +const eventSchema = z.object({ + eventType: z.string().trim().min(1).max(64), + durationMs: z.coerce.number().finite().min(0).max(120_000), + blockingDurationMs: z.coerce.number().finite().min(0).max(120_000).optional(), + section: z.enum(allowedSections).optional(), + action: z.string().trim().max(64).optional(), + domNodeCount: z.coerce.number().int().min(0).max(1_000_000).optional(), + visibleImageCount: z.coerce.number().int().min(0).max(100_000).optional(), + loadedImageCount: z.coerce.number().int().min(0).max(100_000).optional(), + viewport: z + .object({ + width: z.coerce.number().finite().min(0).max(20_000).optional(), + height: z.coerce.number().finite().min(0).max(20_000).optional(), + dpr: z.coerce.number().finite().min(0).max(8).optional(), + }) + .optional(), + browser: z.string().trim().max(128).optional(), + path: z.string().trim().max(TEXT_MAX_LENGTH).optional(), + projectId: z.string().trim().max(64).optional().nullable(), + chatId: z.string().trim().uuid().optional().nullable(), + timestamp: z.union([z.string(), z.number(), z.date()]).optional(), +}); + +type RateWindow = { + windowStart: number; + count: number; +}; + +const rateWindows = new Map(); + +function clampText(value: unknown, maxLength = TEXT_MAX_LENGTH) { + const normalized = String(value || "").trim(); + if (!normalized) return null; + return normalized.slice(0, maxLength); +} + +function stripPath(value: unknown) { + const normalized = clampText(value); + if (!normalized) return null; + if (/https?:\/\//i.test(normalized)) { + try { + return new URL(normalized).pathname.slice(0, TEXT_MAX_LENGTH) || "/"; + } catch { + return "/"; + } + } + return normalized.split("?")[0].split("#")[0].slice(0, TEXT_MAX_LENGTH) || "/"; +} + +function getRequesterIp(request: Request) { + const forwarded = request.headers.get("x-forwarded-for"); + const firstForwarded = forwarded?.split(",")[0]?.trim(); + return ( + firstForwarded || + request.headers.get("x-real-ip") || + request.headers.get("cf-connecting-ip") || + "unknown" + ).slice(0, 64); +} + +function isRateLimited(key: string) { + const now = Date.now(); + const current = rateWindows.get(key); + if (!current || now - current.windowStart >= RATE_LIMIT_WINDOW_MS) { + rateWindows.set(key, { windowStart: now, count: 1 }); + return false; + } + current.count += 1; + return current.count > RATE_LIMIT_MAX_EVENTS; +} + +function normalizeOccurredAt(value: unknown) { + if (!value) return new Date(); + const date = + value instanceof Date + ? value + : typeof value === "number" + ? new Date(value) + : new Date(String(value)); + return Number.isNaN(date.getTime()) ? new Date() : date; +} + +function includesDisallowedPayloadKeys(value: unknown) { + if (!value || typeof value !== "object") return false; + const keys = Object.keys(value as Record).map((key) => key.toLowerCase()); + return keys.some((key) => + [ + "prompt", + "image", + "imageurl", + "imageurls", + "thumbnailurl", + "originalurl", + "url", + "urls", + "input", + "text", + "content", + ].includes(key) + ); +} + +async function maybePruneOldEvents() { + if (Math.random() > 0.01) return; + await db.execute(sql` + DELETE FROM "ClientPerformanceEvent" + WHERE "createdAt" < NOW() - INTERVAL '14 days' + `); +} + +export async function POST(request: Request) { + try { + const contentType = request.headers.get("content-type") || ""; + if (!contentType.toLowerCase().includes("application/json")) { + return NextResponse.json({ ok: true }); + } + + const raw = await request.json().catch(() => null); + if (!raw || typeof raw !== "object" || includesDisallowedPayloadKeys(raw)) { + return NextResponse.json({ ok: true }); + } + + const parsed = eventSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ ok: true }); + } + + const event = parsed.data; + if (event.durationMs < SLOW_EVENT_MIN_DURATION_MS) { + return NextResponse.json({ ok: true }); + } + + const session = await auth().catch(() => null); + const requesterIp = getRequesterIp(request); + const rateKey = `${session?.user?.id || "anon"}:${requesterIp}`; + if (isRateLimited(rateKey)) { + return NextResponse.json({ ok: true }); + } + + await maybePruneOldEvents().catch((error) => { + console.warn("[ClientPerformance] prune failed", error); + }); + + await db.insert(clientPerformanceEvent).values({ + userId: session?.user?.id || null, + chatId: event.chatId || null, + projectId: clampText(event.projectId, 64), + eventType: clampText(event.eventType, 64) || "unknown", + section: event.section || "unknown", + action: clampText(event.action, 64), + path: stripPath(event.path), + durationMs: Math.round(event.durationMs), + blockingDurationMs: Math.round(event.blockingDurationMs || 0), + domNodeCount: event.domNodeCount || 0, + visibleImageCount: event.visibleImageCount || 0, + loadedImageCount: event.loadedImageCount || 0, + viewport: event.viewport || null, + browser: clampText(event.browser, 128), + metadata: { + source: "agent-style-client", + }, + requesterIp, + requesterUserAgent: clampText(request.headers.get("user-agent"), 512), + occurredAt: normalizeOccurredAt(event.timestamp), + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("[ClientPerformance] failed to record agent style event:", error); + return NextResponse.json({ ok: true }); + } +} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 56b2fe83e..a11b906e6 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -62,6 +62,8 @@ import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowSh import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; +import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; +import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor"; import { ImageAnnotator } from "@/components/image-annotator"; import { useCanvasStore } from "@/store/canvas"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; @@ -500,6 +502,11 @@ const AGENT_DEFERRED_RESULTS_STYLE = { containIntrinsicSize: "1px 900px", } as CSSProperties; +const AGENT_DEFERRED_LIST_CARD_STYLE = { + contentVisibility: "auto", + containIntrinsicSize: "1px 320px", +} as CSSProperties; + function handlePreviewImageLoadError( event: SyntheticEvent, originalUrl: string @@ -1233,6 +1240,7 @@ export function AgentGenerationWorkspaceDarkV2({ const nodes = useCanvasStore((state) => state.nodes); const selectNodes = useCanvasStore((state) => state.selectNodes); const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction); + const workspaceRootRef = useRef(null); const fileInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); const hiddenPromptRef = useRef(null); @@ -1278,6 +1286,12 @@ export function AgentGenerationWorkspaceDarkV2({ const isDirectModelGenerationMode = !requiresPresetSelection; const hasMaterialStep = isMaterialStepEnabled && Boolean(materialSelectionPanel); + useAgentStylePerformanceMonitor({ + rootRef: workspaceRootRef, + chatId: rawChatId, + projectId, + }); + const [input, setInput] = useState(normalizedIncomingWorkspaceState.input); const [attachments, setAttachments] = useState>( normalizedIncomingWorkspaceState.attachments.map((item) => @@ -5389,12 +5403,12 @@ export function AgentGenerationWorkspaceDarkV2({ /> {attachment ? ( <> - {attachment.name handlePreviewImageLoadError(event, attachment.url)} />
@@ -5560,12 +5574,12 @@ export function AgentGenerationWorkspaceDarkV2({ /> {attachments[index] ? ( <> - {attachments[index]!.name handlePreviewImageLoadError(event, attachments[index]!.url) } @@ -5656,7 +5670,11 @@ export function AgentGenerationWorkspaceDarkV2({ }, []); return ( -
+
) : null} - +
{hasBrandStoreStep ? (
@@ -5722,6 +5742,7 @@ export function AgentGenerationWorkspaceDarkV2({ id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-5 md:p-6" style={AGENT_DEFERRED_SECTION_STYLE} + data-agent-style-section="style" >
@@ -5748,6 +5769,7 @@ export function AgentGenerationWorkspaceDarkV2({ id="agent-anchor-mode" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4" style={AGENT_DEFERRED_SECTION_STYLE} + data-agent-style-section="mode" >
@@ -5852,6 +5874,7 @@ export function AgentGenerationWorkspaceDarkV2({ id="agent-anchor-materials" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4" style={AGENT_DEFERRED_SECTION_STYLE} + data-agent-style-section="materials" >
@@ -5872,6 +5895,7 @@ export function AgentGenerationWorkspaceDarkV2({ id="agent-anchor-generate" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4" style={AGENT_DEFERRED_SECTION_STYLE} + data-agent-style-section="generate" >
@@ -6197,10 +6221,11 @@ export function AgentGenerationWorkspaceDarkV2({ + animate={{ opacity: 1, scale: 1 }} + transition={{ delay: 0.08 }} + style={AGENT_DEFERRED_SECTION_STYLE} + data-agent-style-section="references" + >
@@ -6412,6 +6437,7 @@ export function AgentGenerationWorkspaceDarkV2({ key={candidate.id} type="button" onClick={() => handleSelectDetailReplacementCandidate(candidate.id)} + style={AGENT_DEFERRED_LIST_CARD_STYLE} className={cn( "group relative min-w-0 overflow-hidden rounded-2xl border p-2 text-left transition-all duration-300", isSelected @@ -6423,12 +6449,12 @@ export function AgentGenerationWorkspaceDarkV2({
{primaryPreviewUrl ? ( - {candidate.sceneName handlePreviewImageLoadError(event, primaryPreviewUrl) } @@ -6512,6 +6538,7 @@ export function AgentGenerationWorkspaceDarkV2({ animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.12 }} style={AGENT_DEFERRED_RESULTS_STYLE} + data-agent-style-section="results" >
@@ -6692,15 +6719,16 @@ export function AgentGenerationWorkspaceDarkV2({ selectedProductAttachments.map((attachment, index) => (
- {attachment!.name handlePreviewImageLoadError(event, attachment!.url) } @@ -6725,12 +6753,12 @@ export function AgentGenerationWorkspaceDarkV2({ {selectedModelAttachment ? (
- {selectedModelAttachment.name handlePreviewImageLoadError(event, selectedModelAttachment.url) } @@ -6781,6 +6809,7 @@ export function AgentGenerationWorkspaceDarkV2({ return (
{card.imageUrl ? ( - {card.label} handlePreviewImageLoadError(event, card.imageUrl || "") } @@ -6899,6 +6928,7 @@ export function AgentGenerationWorkspaceDarkV2({ return (
{card.resultUrl ? (
- {`结果图 handlePreviewImageLoadError(event, card.resultUrl!) } @@ -7063,15 +7093,16 @@ export function AgentGenerationWorkspaceDarkV2({ selectedProductAttachments.map((attachment, index) => (
- {attachment!.name handlePreviewImageLoadError(event, attachment!.url) } @@ -7096,12 +7127,12 @@ export function AgentGenerationWorkspaceDarkV2({ {selectedModelAttachment ? (
- {selectedModelAttachment.name handlePreviewImageLoadError(event, selectedModelAttachment.url) } @@ -7234,6 +7265,7 @@ export function AgentGenerationWorkspaceDarkV2({ key={candidate.id} type="button" onClick={() => handleSelectSkuReplacementCandidate(candidate.id)} + style={AGENT_DEFERRED_LIST_CARD_STYLE} className={cn( "min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors", isSelected @@ -7243,12 +7275,12 @@ export function AgentGenerationWorkspaceDarkV2({ >
{previewUrl ? ( - {candidate.sceneName handlePreviewImageLoadError(event, previewUrl) } @@ -7300,6 +7332,7 @@ export function AgentGenerationWorkspaceDarkV2({ skuReplacementReferenceCards.map((card, index) => (
{card.imageUrl ? ( - {card.label} handlePreviewImageLoadError(event, card.imageUrl || "") } @@ -7427,6 +7460,7 @@ export function AgentGenerationWorkspaceDarkV2({ return (
{card.resultUrl ? (
- {`结果图 handlePreviewImageLoadError(event, card.resultUrl!) } @@ -7576,6 +7610,7 @@ export function AgentGenerationWorkspaceDarkV2({ key={card.id} role={card.url ? "button" : undefined} tabIndex={card.url ? 0 : -1} + style={AGENT_DEFERRED_LIST_CARD_STYLE} onClick={() => { if (!card.url) return; setSelectedResultUrl(card.url); @@ -7605,12 +7640,12 @@ export function AgentGenerationWorkspaceDarkV2({
{card.url ? ( <> - {`${getResultModeLabel(activeResultModeType)} {isSceneResultCard ? ( <> diff --git a/components/agents/AgentPreviewImage.tsx b/components/agents/AgentPreviewImage.tsx new file mode 100644 index 000000000..8fa069b98 --- /dev/null +++ b/components/agents/AgentPreviewImage.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type ImgHTMLAttributes, +} from "react"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; + +type AgentPreviewImageProps = Omit< + ImgHTMLAttributes, + "src" | "loading" | "decoding" +> & { + src?: string | null; + previewWidth?: number; + previewQuality?: number; + previewFormat?: "webp" | "avif" | "jpeg" | "png"; + rootMargin?: string; + eager?: boolean; + raw?: boolean; +}; + +const EMPTY_PIXEL = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + +function buildPreviewUrl(params: { + src: string; + width: number; + quality: number; + format: "webp" | "avif" | "jpeg" | "png"; + raw?: boolean; +}) { + if (params.raw) return params.src; + return ( + buildImageProxyUrl(params.src, { + width: params.width, + quality: params.quality, + format: params.format, + }) || params.src + ); +} + +function AgentPreviewImageBase({ + src, + previewWidth = 480, + previewQuality = 78, + previewFormat = "webp", + rootMargin = "640px 0px", + eager = false, + raw = false, + onError, + onLoad, + ...props +}: AgentPreviewImageProps) { + const imageRef = useRef(null); + const [shouldLoad, setShouldLoad] = useState(eager); + const [useOriginal, setUseOriginal] = useState(false); + const normalizedSrc = String(src || "").trim(); + + useEffect(() => { + setUseOriginal(false); + setShouldLoad(eager); + }, [eager, normalizedSrc]); + + useEffect(() => { + if (eager || shouldLoad || !normalizedSrc) return; + const image = imageRef.current; + if (!image) return; + + if (!("IntersectionObserver" in window)) { + setShouldLoad(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShouldLoad(true); + observer.disconnect(); + } + }, + { rootMargin, threshold: 0.01 } + ); + observer.observe(image); + return () => observer.disconnect(); + }, [eager, normalizedSrc, rootMargin, shouldLoad]); + + const displaySrc = useMemo(() => { + if (!normalizedSrc || !shouldLoad) return EMPTY_PIXEL; + if (useOriginal) return normalizedSrc; + return buildPreviewUrl({ + src: normalizedSrc, + width: previewWidth, + quality: previewQuality, + format: previewFormat, + raw, + }); + }, [ + normalizedSrc, + previewFormat, + previewQuality, + previewWidth, + raw, + shouldLoad, + useOriginal, + ]); + + return ( + { + if (normalizedSrc && !useOriginal && displaySrc !== normalizedSrc) { + setUseOriginal(true); + return; + } + onError?.(event); + }} + onLoad={onLoad} + /> + ); +} + +export const AgentPreviewImage = memo(AgentPreviewImageBase); diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index d20b8e1e0..7e2a7e95e 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -1,10 +1,21 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + memo, + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Check, ImageIcon, Loader2, RefreshCw, Search, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; +import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { processInBrowserBatches } from "@/lib/client/yield-to-browser"; import { cn } from "@/lib/utils"; import type { AgentWorkflowMaterialImage, @@ -46,6 +57,212 @@ interface AgentWorkflowMaterialPickerProps { theme?: "light" | "dark"; } +const MATERIAL_GRID_VIRTUALIZE_THRESHOLD = 12; +const MATERIAL_GRID_GAP = 12; +const MATERIAL_GRID_MIN_CARD_HEIGHT = 258; +const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED = + process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false"; + +const MaterialImageCard = memo(function MaterialImageCard({ + image, + selected, + isDark, + onToggle, +}: { + image: AgentWorkflowMaterialImage; + selected: boolean; + isDark: boolean; + onToggle: (imageId: string) => void; +}) { + const isPlaceholder = !String(image.imageUrl || "").trim(); + return ( + + ); +}); + +function MaterialImageGrid({ + images, + selectedSet, + isDark, + onToggle, +}: { + images: AgentWorkflowMaterialImage[]; + selectedSet: Set; + isDark: boolean; + onToggle: (imageId: string) => void; +}) { + const parentRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const node = parentRef.current; + if (!node || images.length < MATERIAL_GRID_VIRTUALIZE_THRESHOLD) return; + const updateWidth = () => setContainerWidth(node.clientWidth || 0); + updateWidth(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateWidth, { passive: true }); + return () => window.removeEventListener("resize", updateWidth); + } + const observer = new ResizeObserver(updateWidth); + observer.observe(node); + return () => observer.disconnect(); + }, [images.length]); + + const columnCount = useMemo(() => { + if (containerWidth >= 1280) return 4; + if (containerWidth >= 900) return 3; + return 2; + }, [containerWidth]); + + if ( + !AGENT_STYLE_OPTIMIZED_RENDER_ENABLED || + images.length < MATERIAL_GRID_VIRTUALIZE_THRESHOLD + ) { + return ( +
+ {images.map((image) => ( + + ))} +
+ ); + } + + const rowCount = Math.ceil(images.length / columnCount); + const cardWidth = + containerWidth > 0 + ? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount) + : 240; + const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: () => rowHeight + MATERIAL_GRID_GAP, + overscan: 2, + }); + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const start = virtualRow.index * columnCount; + const rowImages = images.slice(start, start + columnCount); + return ( +
+ {rowImages.map((image) => ( + + ))} +
+ ); + })} +
+
+ ); +} + export function AgentWorkflowMaterialPicker({ title, description, @@ -208,7 +425,7 @@ export function AgentWorkflowMaterialPicker({ const hasFetchSuccess = !disabled && !isLoading && !errorText && Boolean(result); - const handleToggleImage = (imageId: string) => { + const handleToggleImage = useCallback((imageId: string) => { setHasManualSelectionInteraction(true); const next = new Set(selectedImageIds); if (next.has(imageId)) { @@ -220,21 +437,38 @@ export function AgentWorkflowMaterialPicker({ } next.add(imageId); } - onSelectedImageIdsChange(Array.from(next)); - }; + const nextIds = Array.from(next); + startTransition(() => { + onSelectedImageIdsChange(nextIds); + }); + }, [ + onSelectedImageIdsChange, + resolvedSelectionLimit, + selectedImageIds, + selectionLimitLabel, + ]); - const handleSelectAll = () => { + const handleSelectAll = useCallback(async () => { setHasManualSelectionInteraction(true); - const allIds = allImages - .filter((item) => Boolean(String(item.imageUrl || "").trim())) - .map((item) => item.id); + const allIds: string[] = []; + await processInBrowserBatches( + allImages, + (item) => { + if (String(item.imageUrl || "").trim()) { + allIds.push(item.id); + } + }, + { batchSize: 80 } + ); const nextIds = resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds; - onSelectedImageIdsChange(nextIds); + startTransition(() => { + onSelectedImageIdsChange(nextIds); + }); if (resolvedSelectionLimit !== null && allIds.length > resolvedSelectionLimit) { toast.message(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片,已按上限选取`); } - }; + }, [allImages, onSelectedImageIdsChange, resolvedSelectionLimit, selectionLimitLabel]); const handleUploadInputChange = async ( file: File | null, @@ -277,12 +511,12 @@ export function AgentWorkflowMaterialPicker({ )} > {hasImage ? ( - {image?.label ) : (
@@ -732,7 +966,9 @@ export function AgentWorkflowMaterialPicker({ size="sm" onClick={() => { setHasManualSelectionInteraction(true); - onSelectedImageIdsChange([]); + startTransition(() => { + onSelectedImageIdsChange([]); + }); }} disabled={selectedCount === 0 && !hasSelectableImages} className={cn( @@ -795,12 +1031,12 @@ export function AgentWorkflowMaterialPicker({ )} > {option.previewImageUrl ? ( - {option.previewFileName ) : (
@@ -862,92 +1098,12 @@ export function AgentWorkflowMaterialPicker({ ) : null} {visibleImages.length > 0 ? ( -
- {visibleImages.map((image) => { - const selected = selectedSet.has(image.id); - const isPlaceholder = !String(image.imageUrl || "").trim(); - return ( - - ); - })} -
+ ) : (
; + chatId?: string | null; + projectId?: string | null; +}; + +const DEFAULT_SAMPLE_RATE = 0.2; +const SLOW_FRAME_THRESHOLD_MS = 120; +const MAX_EVENTS_PER_MINUTE = 20; + +function getBooleanEnvFlag(value: string | undefined, fallback: boolean) { + if (value === undefined || value === "") return fallback; + return !["0", "false", "off", "no"].includes(value.toLowerCase()); +} + +function getSampleRate() { + const raw = Number(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_SAMPLE_RATE); + if (!Number.isFinite(raw)) return DEFAULT_SAMPLE_RATE; + return Math.min(1, Math.max(0, raw)); +} + +function getBrowserLabel() { + const ua = navigator.userAgent || ""; + if (/Edg\//.test(ua)) return "edge"; + if (/Chrome\//.test(ua)) return "chrome"; + if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) return "safari"; + if (/Firefox\//.test(ua)) return "firefox"; + return "unknown"; +} + +function resolveSection(target: EventTarget | null): AgentStyleSection { + if (!(target instanceof Element)) return "unknown"; + const element = target.closest("[data-agent-style-section]"); + const value = element?.dataset.agentStyleSection || "unknown"; + if ( + value === "brand" || + value === "style" || + value === "mode" || + value === "materials" || + value === "generate" || + value === "references" || + value === "results" + ) { + return value; + } + return "unknown"; +} + +function resolveAction(target: EventTarget | null, fallback: string) { + if (!(target instanceof Element)) return fallback; + const explicit = target.closest("[data-agent-style-action]")?.dataset + .agentStyleAction; + if (explicit) return explicit.slice(0, 64); + const element = target.closest("button,input,textarea,select,[role='button']"); + if (!element) return fallback; + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + return `${fallback}:${element.tagName.toLowerCase()}`; + } + return `${fallback}:${String(element.textContent || element.ariaLabel || element.tagName) + .trim() + .slice(0, 32)}`; +} + +function collectCounts(root: HTMLElement) { + const domNodeCount = root.querySelectorAll("*").length; + const images = Array.from(root.querySelectorAll("img")); + let visibleImageCount = 0; + let loadedImageCount = 0; + for (const image of images) { + const rect = image.getBoundingClientRect(); + const visible = + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + if (visible) visibleImageCount += 1; + if (image.complete && image.naturalWidth > 0) loadedImageCount += 1; + } + return { + domNodeCount, + visibleImageCount, + loadedImageCount, + }; +} + +function postEvent(event: AgentStylePerformanceEvent) { + const body = JSON.stringify(event); + if (navigator.sendBeacon) { + const blob = new Blob([body], { type: "application/json" }); + if (navigator.sendBeacon("/api/client-performance/agent-style", blob)) { + return; + } + } + + void fetch("/api/client-performance/agent-style", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }).catch(() => {}); +} + +export function useAgentStylePerformanceMonitor({ + rootRef, + chatId, + projectId, +}: UseAgentStylePerformanceMonitorParams) { + useEffect(() => { + if ( + !getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) || + Math.random() > getSampleRate() + ) { + return; + } + + const root = rootRef.current; + if (!root || typeof PerformanceObserver === "undefined") return; + + let eventCount = 0; + let minuteWindowStart = Date.now(); + let lastSection: AgentStyleSection = "unknown"; + let lastAction = "idle"; + const browser = getBrowserLabel(); + + const markAction = (event: Event) => { + lastSection = resolveSection(event.target); + lastAction = resolveAction(event.target, event.type); + }; + + root.addEventListener("click", markAction, true); + root.addEventListener("input", markAction, true); + root.addEventListener("change", markAction, true); + root.addEventListener("keydown", markAction, true); + root.addEventListener("scroll", markAction, true); + + const trySend = (entry: PerformanceEntry, eventType: string) => { + const now = Date.now(); + if (now - minuteWindowStart >= 60_000) { + minuteWindowStart = now; + eventCount = 0; + } + if (eventCount >= MAX_EVENTS_PER_MINUTE || entry.duration < SLOW_FRAME_THRESHOLD_MS) { + return; + } + eventCount += 1; + + const anyEntry = entry as PerformanceEntry & { + blockingDuration?: number; + renderStart?: number; + styleAndLayoutStart?: number; + }; + const counts = collectCounts(root); + postEvent({ + eventType, + durationMs: Math.round(entry.duration), + blockingDurationMs: Math.round(anyEntry.blockingDuration || 0), + section: lastSection, + action: lastAction, + ...counts, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio || 1, + }, + browser, + path: window.location.pathname, + projectId: projectId || null, + chatId: chatId || null, + timestamp: new Date().toISOString(), + }); + }; + + const observers: PerformanceObserver[] = []; + const supported = PerformanceObserver.supportedEntryTypes || []; + if (supported.includes("long-animation-frame")) { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + trySend(entry, "long-animation-frame"); + } + }); + observer.observe({ type: "long-animation-frame", buffered: true }); + observers.push(observer); + } + + if (supported.includes("longtask")) { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + trySend(entry, "longtask"); + } + }); + observer.observe({ type: "longtask", buffered: true }); + observers.push(observer); + } + + return () => { + for (const observer of observers) { + observer.disconnect(); + } + root.removeEventListener("click", markAction, true); + root.removeEventListener("input", markAction, true); + root.removeEventListener("change", markAction, true); + root.removeEventListener("keydown", markAction, true); + root.removeEventListener("scroll", markAction, true); + }; + }, [chatId, projectId, rootRef]); +} diff --git a/components/image-delivery-optimizer.tsx b/components/image-delivery-optimizer.tsx index 1e6d7b294..29e60bbad 100644 --- a/components/image-delivery-optimizer.tsx +++ b/components/image-delivery-optimizer.tsx @@ -7,6 +7,20 @@ const MIN_DISPLAY_WIDTH = 160; const DEFAULT_DISPLAY_WIDTH = 960; const MAX_DISPLAY_WIDTH = 2200; const OPTIMIZED_MARK = "ossDisplayOptimized"; +const MANUAL_SCOPE_ATTR = "data-image-delivery-scope"; +const BATCH_SIZE = 48; + +type IdleDeadlineLike = { + timeRemaining: () => number; +}; + +type IdleWindow = Window & { + requestIdleCallback?: ( + callback: (deadline: IdleDeadlineLike) => void, + options?: { timeout?: number } + ) => number; + cancelIdleCallback?: (id: number) => void; +}; function isSkippableImageUrl(url: string): boolean { const normalized = url.trim().toLowerCase(); @@ -31,8 +45,19 @@ function getDisplayWidth(image: HTMLImageElement): number { return Math.round(Math.max(MIN_DISPLAY_WIDTH, Math.min(MAX_DISPLAY_WIDTH, width * dpr))); } +function isInManualDeliveryScope(image: HTMLImageElement): boolean { + if ( + image.dataset.imageDelivery === "raw" || + image.dataset.imageDelivery === "proxy" || + image.dataset.imageDelivery === "manual" + ) { + return true; + } + return Boolean(image.closest(`[${MANUAL_SCOPE_ATTR}="manual"]`)); +} + function optimizeImageElement(image: HTMLImageElement): void { - if (image.dataset.imageDelivery === "raw" || image.dataset.imageDelivery === "proxy") { + if (isInManualDeliveryScope(image)) { return; } @@ -59,25 +84,78 @@ function optimizeImageElement(image: HTMLImageElement): void { image.src = optimizedUrl; } -function optimizeAllImages(root: ParentNode = document): void { - root.querySelectorAll("img").forEach((image) => { - optimizeImageElement(image); - }); +function collectImages(root: ParentNode = document): HTMLImageElement[] { + if (root instanceof HTMLImageElement) { + return [root]; + } + if (root instanceof Element && root.closest(`[${MANUAL_SCOPE_ATTR}="manual"]`)) { + return []; + } + return Array.from(root.querySelectorAll("img")); } export function ImageDeliveryOptimizer() { useEffect(() => { - let frameId = 0; - const scheduleOptimize = (root: ParentNode = document) => { - if (frameId) { - cancelAnimationFrame(frameId); + const pendingImages = new Set(); + const idleWindow = window as IdleWindow; + let scheduledId = 0; + let scheduledWithIdle = false; + + const cancelScheduledFlush = () => { + if (!scheduledId) return; + if (scheduledWithIdle && typeof idleWindow.cancelIdleCallback === "function") { + idleWindow.cancelIdleCallback(scheduledId); + } else { + cancelAnimationFrame(scheduledId); } - frameId = requestAnimationFrame(() => { - frameId = 0; - optimizeAllImages(root); + scheduledId = 0; + scheduledWithIdle = false; + }; + + const scheduleFlush = () => { + if (scheduledId) return; + const flush = (deadline?: IdleDeadlineLike) => { + scheduledId = 0; + scheduledWithIdle = false; + let processed = 0; + for (const image of Array.from(pendingImages)) { + pendingImages.delete(image); + if (image.isConnected) { + optimizeImageElement(image); + } + processed += 1; + if ( + processed >= BATCH_SIZE || + (deadline && deadline.timeRemaining() < 4) + ) { + break; + } + } + if (pendingImages.size > 0) { + scheduleFlush(); + } + }; + + if (typeof idleWindow.requestIdleCallback === "function") { + scheduledWithIdle = true; + scheduledId = idleWindow.requestIdleCallback(flush, { timeout: 500 }); + return; + } + + scheduledId = requestAnimationFrame(() => { + flush({ timeRemaining: () => 8 }); }); }; + const scheduleOptimize = (root: ParentNode = document) => { + for (const image of collectImages(root)) { + pendingImages.add(image); + } + if (pendingImages.size > 0) { + scheduleFlush(); + } + }; + scheduleOptimize(); const observer = new MutationObserver((mutations) => { @@ -85,14 +163,16 @@ export function ImageDeliveryOptimizer() { if (mutation.type === "attributes") { const target = mutation.target; if (target instanceof HTMLImageElement) { - optimizeImageElement(target); + pendingImages.add(target); + scheduleFlush(); } continue; } for (const node of mutation.addedNodes) { if (node instanceof HTMLImageElement) { - optimizeImageElement(node); + pendingImages.add(node); + scheduleFlush(); } else if (node instanceof Element) { scheduleOptimize(node); } @@ -111,9 +191,7 @@ export function ImageDeliveryOptimizer() { window.addEventListener("resize", handleResize, { passive: true }); return () => { - if (frameId) { - cancelAnimationFrame(frameId); - } + cancelScheduledFlush(); observer.disconnect(); window.removeEventListener("resize", handleResize); }; diff --git a/lib/client/yield-to-browser.ts b/lib/client/yield-to-browser.ts new file mode 100644 index 000000000..3786e1ed2 --- /dev/null +++ b/lib/client/yield-to-browser.ts @@ -0,0 +1,37 @@ +type SchedulerLike = { + yield?: () => Promise; +}; + +export async function yieldToBrowser() { + const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler; + if (typeof scheduler?.yield === "function") { + await scheduler.yield(); + return; + } + + await new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + setTimeout(resolve, 0); + }); + return; + } + setTimeout(resolve, 0); + }); +} + +export async function processInBrowserBatches( + items: T[], + worker: (item: T, index: number) => void | Promise, + options?: { + batchSize?: number; + } +) { + const batchSize = Math.max(1, options?.batchSize ?? 24); + for (let index = 0; index < items.length; index += 1) { + await worker(items[index], index); + if ((index + 1) % batchSize === 0) { + await yieldToBrowser(); + } + } +} diff --git a/lib/db/migrations/0082_add_client_performance_events.sql b/lib/db/migrations/0082_add_client_performance_events.sql new file mode 100644 index 000000000..84cfb9acb --- /dev/null +++ b/lib/db/migrations/0082_add_client_performance_events.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS "ClientPerformanceEvent" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" uuid REFERENCES "User"("id") ON DELETE SET NULL, + "chatId" uuid REFERENCES "Chat"("id") ON DELETE SET NULL, + "projectId" varchar(64), + "eventType" varchar(64) NOT NULL, + "section" varchar(32) DEFAULT 'unknown' NOT NULL, + "action" varchar(64), + "path" varchar(256), + "durationMs" integer DEFAULT 0 NOT NULL, + "blockingDurationMs" integer DEFAULT 0 NOT NULL, + "domNodeCount" integer DEFAULT 0 NOT NULL, + "visibleImageCount" integer DEFAULT 0 NOT NULL, + "loadedImageCount" integer DEFAULT 0 NOT NULL, + "viewport" jsonb, + "browser" varchar(128), + "metadata" jsonb, + "requesterIp" varchar(64), + "requesterUserAgent" text, + "occurredAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "ClientPerformanceEvent_createdAt_idx" + ON "ClientPerformanceEvent" ("createdAt" DESC); + +CREATE INDEX IF NOT EXISTS "ClientPerformanceEvent_section_createdAt_idx" + ON "ClientPerformanceEvent" ("section", "createdAt" DESC); + +CREATE INDEX IF NOT EXISTS "ClientPerformanceEvent_userId_createdAt_idx" + ON "ClientPerformanceEvent" ("userId", "createdAt" DESC); + +CREATE INDEX IF NOT EXISTS "ClientPerformanceEvent_projectId_createdAt_idx" + ON "ClientPerformanceEvent" ("projectId", "createdAt" DESC); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index d468c50f0..0ffa0cf27 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -477,6 +477,13 @@ "when": 1779069600000, "tag": "0081_add_external_image_api_logs", "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1779267679691, + "tag": "0082_add_client_performance_events", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ac1b42322..ef2063801 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -613,6 +613,33 @@ export type ExternalImageApiRequestLog = InferSelectModel< typeof externalImageApiRequestLog >; +export const clientPerformanceEvent = pgTable("ClientPerformanceEvent", { + id: uuid("id").primaryKey().notNull().defaultRandom(), + userId: uuid("userId").references(() => user.id, { onDelete: "set null" }), + chatId: uuid("chatId").references(() => chat.id, { onDelete: "set null" }), + projectId: varchar("projectId", { length: 64 }), + eventType: varchar("eventType", { length: 64 }).notNull(), + section: varchar("section", { length: 32 }).notNull().default("unknown"), + action: varchar("action", { length: 64 }), + path: varchar("path", { length: 256 }), + durationMs: integer("durationMs").notNull().default(0), + blockingDurationMs: integer("blockingDurationMs").notNull().default(0), + domNodeCount: integer("domNodeCount").notNull().default(0), + visibleImageCount: integer("visibleImageCount").notNull().default(0), + loadedImageCount: integer("loadedImageCount").notNull().default(0), + viewport: jsonb("viewport"), + browser: varchar("browser", { length: 128 }), + metadata: jsonb("metadata"), + requesterIp: varchar("requesterIp", { length: 64 }), + requesterUserAgent: text("requesterUserAgent"), + occurredAt: timestamp("occurredAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export type ClientPerformanceEvent = InferSelectModel< + typeof clientPerformanceEvent +>; + // 风格化分类表 export const agentPresetCategory = pgTable("AgentPresetCategory", { id: uuid("id").primaryKey().notNull().defaultRandom(),