perf: optimize agent style rendering
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-20 18:10:43 +08:00
parent d6ab02eb45
commit 68eab912d8
10 changed files with 1127 additions and 183 deletions

View File

@ -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<string, RateWindow>();
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<string, unknown>).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 });
}
}

View File

@ -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<HTMLImageElement, Event>,
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<HTMLDivElement | null>(null);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(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<Array<PickedAttachment | null>>(
normalizedIncomingWorkspaceState.attachments.map((item) =>
@ -5389,12 +5403,12 @@ export function AgentGenerationWorkspaceDarkV2({
/>
{attachment ? (
<>
<img
src={buildPreviewImageUrl(attachment.url, 360)}
<AgentPreviewImage
src={attachment.url}
alt={attachment.name || slot.label}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={360}
previewQuality={76}
onError={(event) => handlePreviewImageLoadError(event, attachment.url)}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-2 py-1.5 text-[11px] text-white">
@ -5560,12 +5574,12 @@ export function AgentGenerationWorkspaceDarkV2({
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
<AgentPreviewImage
src={attachments[index]!.url}
alt={attachments[index]!.name || slot.label}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={320}
previewQuality={74}
onError={(event) =>
handlePreviewImageLoadError(event, attachments[index]!.url)
}
@ -5656,7 +5670,11 @@ export function AgentGenerationWorkspaceDarkV2({
}, []);
return (
<div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-black">
<div
ref={workspaceRootRef}
className="flex h-dvh min-h-0 flex-col overflow-hidden bg-black"
data-image-delivery-scope="manual"
>
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
@ -5686,19 +5704,21 @@ export function AgentGenerationWorkspaceDarkV2({
</motion.section>
) : null}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06 }}
className="rounded-[28px] border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
style={AGENT_DEFERRED_SECTION_STYLE}
>
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06 }}
className="rounded-[28px] border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
style={AGENT_DEFERRED_SECTION_STYLE}
data-agent-style-section="brand"
>
<div className="space-y-6">
{hasBrandStoreStep ? (
<section
id="agent-anchor-brand"
className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4"
style={AGENT_DEFERRED_SECTION_STYLE}
data-agent-style-section="brand"
>
<div className="mb-4 flex flex-col gap-4 border-b border-white/10 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
@ -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"
>
<div className="mb-5 space-y-1 border-b border-white/10 pb-5">
<div className="flex items-center gap-2">
@ -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"
>
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
@ -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"
>
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
@ -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"
>
<div className="mb-4 space-y-1 border-b border-white/10 pb-4">
<div className="flex items-center gap-2">
@ -6197,10 +6221,11 @@ export function AgentGenerationWorkspaceDarkV2({
<motion.section
className="relative overflow-hidden rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.08 }}
style={AGENT_DEFERRED_SECTION_STYLE}
>
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.08 }}
style={AGENT_DEFERRED_SECTION_STYLE}
data-agent-style-section="references"
>
<div className="border-b border-white/12 px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
@ -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({
<div className="relative w-[72px] min-h-[96px] shrink-0 overflow-hidden rounded-[18px] border border-white/10 bg-white">
<div className="h-full w-full">
{primaryPreviewUrl ? (
<img
src={buildPreviewImageUrl(primaryPreviewUrl, 360)}
<AgentPreviewImage
src={primaryPreviewUrl}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
loading="lazy"
decoding="async"
className="h-full w-full object-contain object-top transition-transform duration-500 group-hover:scale-[1.02]"
previewWidth={360}
previewQuality={72}
onError={(event) =>
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"
>
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
<div>
@ -6692,15 +6719,16 @@ export function AgentGenerationWorkspaceDarkV2({
selectedProductAttachments.map((attachment, index) => (
<div
key={`${attachment?.url || "input"}-${index}`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(attachment!.url, 400)}
<AgentPreviewImage
src={attachment!.url}
alt={attachment!.name || `输入图 ${index + 1}`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={400}
previewQuality={74}
onError={(event) =>
handlePreviewImageLoadError(event, attachment!.url)
}
@ -6725,12 +6753,12 @@ export function AgentGenerationWorkspaceDarkV2({
{selectedModelAttachment ? (
<div className="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
<img
src={buildPreviewImageUrl(selectedModelAttachment.url, 720)}
<AgentPreviewImage
src={selectedModelAttachment.url}
alt={selectedModelAttachment.name || "模特图"}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={720}
previewQuality={76}
onError={(event) =>
handlePreviewImageLoadError(event, selectedModelAttachment.url)
}
@ -6781,6 +6809,7 @@ export function AgentGenerationWorkspaceDarkV2({
return (
<div
key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
@ -6819,12 +6848,12 @@ export function AgentGenerationWorkspaceDarkV2({
</button>
</div>
{card.imageUrl ? (
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
<AgentPreviewImage
src={card.imageUrl}
alt={card.label}
loading="lazy"
decoding="async"
className="block h-auto w-full"
previewWidth={720}
previewQuality={76}
onError={(event) =>
handlePreviewImageLoadError(event, card.imageUrl || "")
}
@ -6899,6 +6928,7 @@ export function AgentGenerationWorkspaceDarkV2({
return (
<div
key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
hasResult && isActive
@ -6910,12 +6940,12 @@ export function AgentGenerationWorkspaceDarkV2({
>
{card.resultUrl ? (
<div className="relative min-h-[220px] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
<AgentPreviewImage
src={card.resultUrl}
alt={`结果图 ${card.index + 1}`}
loading="lazy"
decoding="async"
className="block h-auto w-full cursor-zoom-in"
previewWidth={720}
previewQuality={78}
onError={(event) =>
handlePreviewImageLoadError(event, card.resultUrl!)
}
@ -7063,15 +7093,16 @@ export function AgentGenerationWorkspaceDarkV2({
selectedProductAttachments.map((attachment, index) => (
<div
key={`${attachment?.url || "sku-input"}-${index}`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
>
<div className="aspect-square overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(attachment!.url, 400)}
<AgentPreviewImage
src={attachment!.url}
alt={attachment!.name || `输入图 ${index + 1}`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={400}
previewQuality={74}
onError={(event) =>
handlePreviewImageLoadError(event, attachment!.url)
}
@ -7096,12 +7127,12 @@ export function AgentGenerationWorkspaceDarkV2({
{selectedModelAttachment ? (
<div className="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
<img
src={buildPreviewImageUrl(selectedModelAttachment.url, 720)}
<AgentPreviewImage
src={selectedModelAttachment.url}
alt={selectedModelAttachment.name || "模特图"}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={720}
previewQuality={76}
onError={(event) =>
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({
>
<div className="relative aspect-[16/9] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.18),transparent_58%),linear-gradient(180deg,#0b0d0f_0%,#11161a_100%)]">
{previewUrl ? (
<img
src={buildPreviewImageUrl(previewUrl, 640)}
<AgentPreviewImage
src={previewUrl}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={640}
previewQuality={74}
onError={(event) =>
handlePreviewImageLoadError(event, previewUrl)
}
@ -7300,6 +7332,7 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards.map((card, index) => (
<div
key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
@ -7329,12 +7362,12 @@ export function AgentGenerationWorkspaceDarkV2({
删除
</button>
{card.imageUrl ? (
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
<AgentPreviewImage
src={card.imageUrl}
alt={card.label}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
previewWidth={720}
previewQuality={76}
onError={(event) =>
handlePreviewImageLoadError(event, card.imageUrl || "")
}
@ -7427,6 +7460,7 @@ export function AgentGenerationWorkspaceDarkV2({
return (
<div
key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
hasResult && activeSkuReferenceCardId === card.id
@ -7438,12 +7472,12 @@ export function AgentGenerationWorkspaceDarkV2({
>
{card.resultUrl ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
<AgentPreviewImage
src={card.resultUrl}
alt={`结果图 ${card.index + 1}`}
loading="lazy"
decoding="async"
className="h-full w-full cursor-zoom-in object-cover"
previewWidth={720}
previewQuality={78}
onError={(event) =>
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({
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
{card.url ? (
<>
<img
<AgentPreviewImage
src={card.url}
alt={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
loading="lazy"
decoding="async"
previewWidth={720}
previewQuality={78}
/>
{isSceneResultCard ? (
<>

View File

@ -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<HTMLImageElement>,
"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<HTMLImageElement | null>(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 (
<img
{...props}
ref={imageRef}
src={displaySrc}
data-image-delivery="manual"
loading={eager ? "eager" : "lazy"}
decoding="async"
onError={(event) => {
if (normalizedSrc && !useOriginal && displaySrc !== normalizedSrc) {
setUseOriginal(true);
return;
}
onError?.(event);
}}
onLoad={onLoad}
/>
);
}
export const AgentPreviewImage = memo(AgentPreviewImageBase);

View File

@ -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 (
<button
type="button"
onClick={() => {
if (isPlaceholder) return;
onToggle(image.id);
}}
disabled={isPlaceholder}
className={cn(
"group overflow-hidden rounded-[24px] border bg-background text-left transition-all",
isPlaceholder && "cursor-wait",
selected
? isDark
? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
{isPlaceholder ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]">
<Loader2 className="h-6 w-6 animate-spin text-white/55" />
<div className="h-2 w-20 rounded-full bg-white/10" />
<div className="h-2 w-14 rounded-full bg-white/10" />
</div>
) : (
<AgentPreviewImage
src={image.imageUrl}
alt={image.label}
className="h-full w-full object-cover"
previewWidth={480}
previewQuality={74}
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
{isPlaceholder ? "加载中" : image.group === "fixed" ? "固定角度" : "其他图片"}
</span>
{!isPlaceholder ? (
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
<Check className="h-3.5 w-3.5" />
</span>
) : null}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
{isPlaceholder ? "白底图加载中..." : image.label}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/88" : "")}>
{isPlaceholder ? "正在获取 SKU 图片..." : image.fileName}
</div>
<div className={cn("text-[11px]", isDark ? "text-white/60" : "text-muted-foreground")}>
{isPlaceholder
? "已先展示预览卡位,图片会逐步加载出来。"
: `系统会先按权重自动选图,你也可以点击${selected ? "取消" : "调整"}。`}
</div>
</div>
</button>
);
});
function MaterialImageGrid({
images,
selectedSet,
isDark,
onToggle,
}: {
images: AgentWorkflowMaterialImage[];
selectedSet: Set<string>;
isDark: boolean;
onToggle: (imageId: string) => void;
}) {
const parentRef = useRef<HTMLDivElement | null>(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 (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{images.map((image) => (
<MaterialImageCard
key={image.id}
image={image}
selected={selectedSet.has(image.id)}
isDark={isDark}
onToggle={onToggle}
/>
))}
</div>
);
}
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 (
<div
ref={parentRef}
className="agent-dark-scrollbar max-h-[680px] overflow-y-auto pr-1"
data-agent-style-action="scroll-material-images"
>
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const start = virtualRow.index * columnCount;
const rowImages = images.slice(start, start + columnCount);
return (
<div
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 grid w-full gap-3"
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowImages.map((image) => (
<MaterialImageCard
key={image.id}
image={image}
selected={selectedSet.has(image.id)}
isDark={isDark}
onToggle={onToggle}
/>
))}
</div>
);
})}
</div>
</div>
);
}
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 ? (
<img
<AgentPreviewImage
src={image?.imageUrl}
alt={image?.label || "上传素材"}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
previewWidth={480}
previewQuality={78}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-4 text-center">
@ -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 ? (
<img
<AgentPreviewImage
src={option.previewImageUrl}
alt={option.previewFileName || option.skuName}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
previewWidth={320}
previewQuality={72}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/30">
@ -862,92 +1098,12 @@ export function AgentWorkflowMaterialPicker({
) : null}
{visibleImages.length > 0 ? (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{visibleImages.map((image) => {
const selected = selectedSet.has(image.id);
const isPlaceholder = !String(image.imageUrl || "").trim();
return (
<button
key={image.id}
type="button"
onClick={() => {
if (isPlaceholder) return;
handleToggleImage(image.id);
}}
disabled={isPlaceholder}
className={cn(
"group overflow-hidden rounded-[24px] border bg-background text-left transition-all",
isPlaceholder && "cursor-wait",
selected
? isDark
? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
{isPlaceholder ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]">
<Loader2 className="h-6 w-6 animate-spin text-white/55" />
<div className="h-2 w-20 rounded-full bg-white/10" />
<div className="h-2 w-14 rounded-full bg-white/10" />
</div>
) : (
<img
src={image.imageUrl}
alt={image.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
{isPlaceholder ? "加载中" : image.group === "fixed" ? "固定角度" : "其他图片"}
</span>
{!isPlaceholder ? (
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
<Check className="h-3.5 w-3.5" />
</span>
) : null}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
{isPlaceholder ? "白底图加载中..." : image.label}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/88" : "")}>
{isPlaceholder ? "正在获取 SKU 图片..." : image.fileName}
</div>
<div className={cn("text-[11px]", isDark ? "text-white/60" : "text-muted-foreground")}>
{isPlaceholder
? "已先展示预览卡位,图片会逐步加载出来。"
: `系统会先按权重自动选图,你也可以点击${selected ? "取消" : "调整"}。`}
</div>
</div>
</button>
);
})}
</div>
<MaterialImageGrid
images={visibleImages}
selectedSet={selectedSet}
isDark={isDark}
onToggle={handleToggleImage}
/>
) : (
<div
className={cn(

View File

@ -0,0 +1,241 @@
"use client";
import { useEffect, type RefObject } from "react";
export type AgentStyleSection =
| "brand"
| "style"
| "mode"
| "materials"
| "generate"
| "references"
| "results"
| "unknown";
export type AgentStylePerformanceEvent = {
eventType: string;
durationMs: number;
blockingDurationMs?: number;
section: AgentStyleSection;
action?: string;
domNodeCount?: number;
visibleImageCount?: number;
loadedImageCount?: number;
viewport?: {
width: number;
height: number;
dpr: number;
};
browser?: string;
path?: string;
projectId?: string | null;
chatId?: string | null;
timestamp: string;
};
type UseAgentStylePerformanceMonitorParams = {
rootRef: RefObject<HTMLElement | null>;
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<HTMLElement>("[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<HTMLElement>("[data-agent-style-action]")?.dataset
.agentStyleAction;
if (explicit) return explicit.slice(0, 64);
const element = target.closest<HTMLElement>("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]);
}

View File

@ -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<HTMLImageElement>();
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);
};

View File

@ -0,0 +1,37 @@
type SchedulerLike = {
yield?: () => Promise<void>;
};
export async function yieldToBrowser() {
const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler;
if (typeof scheduler?.yield === "function") {
await scheduler.yield();
return;
}
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
return;
}
setTimeout(resolve, 0);
});
}
export async function processInBrowserBatches<T>(
items: T[],
worker: (item: T, index: number) => void | Promise<void>,
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();
}
}
}

View File

@ -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);

View File

@ -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
}
]
}

View File

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