perf: optimize agent style rendering
This commit is contained in:
parent
d6ab02eb45
commit
68eab912d8
198
app/api/client-performance/agent-style/route.ts
Normal file
198
app/api/client-performance/agent-style/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 ? (
|
||||
<>
|
||||
|
||||
131
components/agents/AgentPreviewImage.tsx
Normal file
131
components/agents/AgentPreviewImage.tsx
Normal 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);
|
||||
@ -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(
|
||||
|
||||
241
components/agents/use-agent-style-performance-monitor.ts
Normal file
241
components/agents/use-agent-style-performance-monitor.ts
Normal 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]);
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
|
||||
37
lib/client/yield-to-browser.ts
Normal file
37
lib/client/yield-to-browser.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lib/db/migrations/0082_add_client_performance_events.sql
Normal file
34
lib/db/migrations/0082_add_client_performance_events.sql
Normal 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);
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user