ai-chatbot/components/canvas/RealtimeCanvasDialog.tsx
Your Name 080328fbeb
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: refine realtime canvas dialog ui
2026-02-22 03:24:44 +08:00

781 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import * as fal from "@fal-ai/serverless-client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
Eraser,
Loader2,
Paintbrush,
RefreshCw,
Send,
Sparkles,
Trash2,
Wifi,
WifiOff,
} from "lucide-react";
import { toast } from "sonner";
type RealtimeConfig = {
transport: {
throttleInterval: number;
maxBuffering: number;
};
image: {
maxInputResolution: number;
jpegQuality: number;
};
model: {
id: string;
defaults: {
steps: number;
scheduleMu: number;
feedback: number;
imageSize: "square" | "square_hd";
};
};
};
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
type RealtimeCanvasDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
initialImageUrl?: string | null;
onApplyResult: (payload: { imageUrl: string; prompt: string }) => void;
};
const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。";
const defaultRealtimeConfig: RealtimeConfig = {
transport: {
throttleInterval: 100,
maxBuffering: 2,
},
image: {
maxInputResolution: 1024,
jpegQuality: 0.55,
},
model: {
id: "fal-ai/flux-2/klein/realtime",
defaults: {
steps: 3,
scheduleMu: 2.3,
feedback: 0.95,
imageSize: "square",
},
},
};
function buildImageDataUrl(image: any) {
if (!image) return "";
if (typeof image.url === "string" && image.url.trim()) {
return image.url;
}
if (typeof image.content === "string" && image.content.trim()) {
if (image.content.startsWith("data:")) {
return image.content;
}
const contentType = image.content_type || "image/jpeg";
return `data:${contentType};base64,${image.content}`;
}
return "";
}
async function canvasToJpegDataUri(
canvas: HTMLCanvasElement,
maxInputResolution: number,
jpegQuality: number
) {
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
const longestSide = Math.max(sourceWidth, sourceHeight);
const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1;
const exportWidth = Math.max(1, Math.round(sourceWidth * scale));
const exportHeight = Math.max(1, Math.round(sourceHeight * scale));
const exportCanvas = document.createElement("canvas");
exportCanvas.width = exportWidth;
exportCanvas.height = exportHeight;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) {
throw new Error("无法初始化导出画布");
}
exportCtx.fillStyle = "#d8d8db";
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
const blob = await new Promise<Blob | null>((resolve) => {
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
});
if (!blob) {
throw new Error("导出图片失败");
}
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取导出图片失败"));
reader.readAsDataURL(blob);
});
}
export function RealtimeCanvasDialog({
open,
onOpenChange,
initialImageUrl,
onApplyResult,
}: RealtimeCanvasDialogProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const connectionRef = useRef<any>(null);
const pointerDownRef = useRef(false);
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const pendingFlushTimerRef = useRef<number | null>(null);
const flushInFlightRef = useRef(false);
const dirtyRef = useRef(false);
const seqRef = useRef(0);
const requestSeqMapRef = useRef<Map<string, number>>(new Map());
const requestStartMapRef = useRef<Map<string, number>>(new Map());
const lastRenderedSeqRef = useRef(0);
const metricsQueueRef = useRef<any[]>([]);
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const hasLoadedInitialImageRef = useRef(false);
const [config, setConfig] = useState<RealtimeConfig>(defaultRealtimeConfig);
const [status, setStatus] = useState<RealtimeStatus>("idle");
const [statusMessage, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
const [previewUrl, setPreviewUrl] = useState("");
const [isApplying, setIsApplying] = useState(false);
const [brushMode, setBrushMode] = useState<"brush" | "eraser">("brush");
const [brushSize, setBrushSize] = useState(6);
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval
);
const [refreshToken, setRefreshToken] = useState(0);
const statusBadge = useMemo(() => {
if (status === "live") return { label: "实时中", className: "bg-emerald-50 text-emerald-700" };
if (status === "connecting") {
return { label: "连接中", className: "bg-blue-50 text-blue-700" };
}
if (status === "error") return { label: "异常", className: "bg-rose-50 text-rose-700" };
return { label: "待启动", className: "bg-zinc-100 text-zinc-600" };
}, [status]);
const pushMetric = useCallback((event: any) => {
metricsQueueRef.current.push(event);
if (metricsQueueRef.current.length > 120) {
metricsQueueRef.current = metricsQueueRef.current.slice(-80);
}
}, []);
const flushMetrics = useCallback(async () => {
if (!open || metricsQueueRef.current.length === 0) return;
const events = metricsQueueRef.current.splice(0, metricsQueueRef.current.length);
try {
await fetch("/api/realtime/metrics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: sessionIdRef.current,
events,
}),
});
} catch {
metricsQueueRef.current.unshift(...events.slice(-20));
}
}, [open]);
const resetCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#d8d8db";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}, []);
const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const image = new Image();
image.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("加载初始图片失败"));
image.src = initialImageUrl;
});
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const offsetX = (canvas.width - drawWidth) / 2;
const offsetY = (canvas.height - drawHeight) / 2;
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
hasLoadedInitialImageRef.current = true;
dirtyRef.current = true;
}, [initialImageUrl, open]);
const flushFrame = useCallback(
async (force = false) => {
if (!open) return;
if (!connectionRef.current) return;
if (flushInFlightRef.current) return;
if (!dirtyRef.current && !force) return;
const canvas = canvasRef.current;
if (!canvas) return;
flushInFlightRef.current = true;
const seq = seqRef.current + 1;
seqRef.current = seq;
const requestId = `${sessionIdRef.current}-${seq}`;
requestSeqMapRef.current.set(requestId, seq);
requestStartMapRef.current.set(requestId, Date.now());
try {
const imageDataUri = await canvasToJpegDataUri(
canvas,
config.image.maxInputResolution,
config.image.jpegQuality
);
dirtyRef.current = false;
setStatus("connecting");
setStatusMessage("正在请求实时生成...");
connectionRef.current.send({
request_id: requestId,
image_url: imageDataUri,
prompt: prompt.trim() || FALLBACK_PROMPT,
image_size: config.model.defaults.imageSize,
num_inference_steps: config.model.defaults.steps,
schedule_mu: config.model.defaults.scheduleMu,
output_feedback_strength: config.model.defaults.feedback,
});
pushMetric({
name: "frame_send",
ts: Date.now(),
seq,
});
} catch (error) {
console.error("[RealtimeCanvas] flush frame failed:", error);
setStatus("error");
setStatusMessage(error instanceof Error ? error.message : "发送失败");
} finally {
flushInFlightRef.current = false;
}
},
[config, open, prompt, pushMetric]
);
const scheduleFlush = useCallback(
(force = false) => {
dirtyRef.current = true;
if (force) {
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
void flushFrame(true);
return;
}
if (pendingFlushTimerRef.current) return;
pendingFlushTimerRef.current = window.setTimeout(() => {
pendingFlushTimerRef.current = null;
void flushFrame();
}, adaptiveThrottleMs);
},
[adaptiveThrottleMs, flushFrame]
);
const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrap = canvasWrapRef.current;
if (!canvas || !wrap) return;
const rect = wrap.getBoundingClientRect();
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.max(640, Math.floor(rect.width * dpr));
canvas.height = Math.max(480, Math.floor(rect.height * dpr));
canvas.style.width = `${Math.floor(rect.width)}px`;
canvas.style.height = `${Math.floor(rect.height)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
resetCanvas();
hasLoadedInitialImageRef.current = false;
}, [resetCanvas]);
const getCanvasPoint = useCallback((event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}, []);
const drawStroke = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
if (brushMode === "eraser") {
ctx.globalCompositeOperation = "destination-out";
ctx.strokeStyle = "rgba(0,0,0,1)";
ctx.lineWidth = brushSize * 1.45;
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#18181b";
ctx.lineWidth = brushSize;
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
ctx.restore();
},
[brushMode, brushSize]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = getCanvasPoint(event);
if (!point) return;
pointerDownRef.current = true;
lastPointRef.current = point;
event.currentTarget.setPointerCapture(event.pointerId);
},
[getCanvasPoint]
);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!pointerDownRef.current) return;
const point = getCanvasPoint(event);
if (!point || !lastPointRef.current) return;
drawStroke(lastPointRef.current, point);
lastPointRef.current = point;
scheduleFlush();
},
[drawStroke, getCanvasPoint, scheduleFlush]
);
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
pointerDownRef.current = false;
lastPointRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
scheduleFlush(true);
},
[scheduleFlush]
);
const uploadPreviewToCanvas = useCallback(async () => {
if (!previewUrl) {
toast.error("请先完成一帧实时预览");
return;
}
setIsApplying(true);
try {
const blob = await fetch(previewUrl).then((res) => res.blob());
const formData = new FormData();
const filename = `realtime-canvas-${Date.now()}.jpg`;
formData.append("file", new File([blob], filename, { type: blob.type || "image/jpeg" }));
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传到画布失败");
}
onApplyResult({
imageUrl: String(result.url),
prompt: prompt.trim() || FALLBACK_PROMPT,
});
toast.success("已发送到画布");
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送到画布失败");
} finally {
setIsApplying(false);
}
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
useEffect(() => {
if (!open) return;
initializeCanvas();
void drawInitialImage().then(() => {
scheduleFlush(true);
});
const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [drawInitialImage, initializeCanvas, open, scheduleFlush]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setStatus("connecting");
setStatusMessage("正在初始化实时连接...");
const start = async () => {
try {
const configResponse = await fetch("/api/realtime/config", { cache: "no-store" });
const configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig>;
if (!configResponse.ok) {
throw new Error((configJson as any)?.message || "无法加载实时配置");
}
if (cancelled) return;
const resolvedConfig: RealtimeConfig = {
transport: {
throttleInterval:
configJson.transport?.throttleInterval ||
defaultRealtimeConfig.transport.throttleInterval,
maxBuffering:
configJson.transport?.maxBuffering || defaultRealtimeConfig.transport.maxBuffering,
},
image: {
maxInputResolution:
configJson.image?.maxInputResolution || defaultRealtimeConfig.image.maxInputResolution,
jpegQuality: configJson.image?.jpegQuality || defaultRealtimeConfig.image.jpegQuality,
},
model: {
id: configJson.model?.id || defaultRealtimeConfig.model.id,
defaults: {
steps: configJson.model?.defaults?.steps || defaultRealtimeConfig.model.defaults.steps,
scheduleMu:
configJson.model?.defaults?.scheduleMu ||
defaultRealtimeConfig.model.defaults.scheduleMu,
feedback:
configJson.model?.defaults?.feedback ||
defaultRealtimeConfig.model.defaults.feedback,
imageSize:
configJson.model?.defaults?.imageSize ||
defaultRealtimeConfig.model.defaults.imageSize,
},
},
};
setConfig(resolvedConfig);
setAdaptiveThrottleMs(resolvedConfig.transport.throttleInterval);
fal.config({
proxyUrl: "/api/fal/proxy",
});
const connection = fal.realtime.connect(resolvedConfig.model.id, {
connectionKey: `canvas-realtime-${sessionIdRef.current}-${refreshToken}`,
throttleInterval: resolvedConfig.transport.throttleInterval,
maxBuffering: resolvedConfig.transport.maxBuffering,
onResult: (result: any) => {
const requestId = String(result?.request_id || "");
const seqFromRequest = requestSeqMapRef.current.get(requestId) || 0;
const seq = Math.max(seqFromRequest, Number(result?.seq || 0));
if (seq > 0 && seq < lastRenderedSeqRef.current) {
return;
}
const latestImage = Array.isArray(result?.images)
? result.images[result.images.length - 1]
: null;
const nextPreviewUrl = buildImageDataUrl(latestImage);
if (!nextPreviewUrl) return;
lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq);
setPreviewUrl(nextPreviewUrl);
setStatus("live");
setStatusMessage("实时渲染中");
if (requestId) {
const startedAt = requestStartMapRef.current.get(requestId);
if (startedAt) {
pushMetric({
name: "frame_result",
ts: Date.now(),
seq,
latencyMs: Date.now() - startedAt,
});
}
requestStartMapRef.current.delete(requestId);
requestSeqMapRef.current.delete(requestId);
}
},
onError: (error: any) => {
const errorMessage =
error?.body?.message || error?.message || "实时连接异常,请稍后重试";
setStatus("error");
setStatusMessage(String(errorMessage));
if (Number(error?.status) === 429) {
setAdaptiveThrottleMs((prev) => Math.min(240, prev + 40));
}
pushMetric({
name: "frame_error",
ts: Date.now(),
message: String(errorMessage),
});
},
});
if (cancelled) {
connection.close();
return;
}
connectionRef.current = connection;
setStatus("connecting");
setStatusMessage("连接成功,等待首帧...");
scheduleFlush(true);
} catch (error) {
console.error("[RealtimeCanvas] init failed:", error);
setStatus("error");
setStatusMessage(error instanceof Error ? error.message : "实时初始化失败");
}
};
void start();
const metricTimer = window.setInterval(() => {
void flushMetrics();
}, 5000);
return () => {
cancelled = true;
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
window.clearInterval(metricTimer);
void flushMetrics();
connectionRef.current?.close?.();
connectionRef.current = null;
setStatus("idle");
setStatusMessage("等待连接");
pointerDownRef.current = false;
lastPointRef.current = null;
};
}, [flushMetrics, open, pushMetric, refreshToken, scheduleFlush]);
const forceReconnect = useCallback(() => {
connectionRef.current?.close?.();
connectionRef.current = null;
setStatus("connecting");
setStatusMessage("正在重连...");
setRefreshToken((prev) => prev + 1);
}, []);
const clearSketch = useCallback(() => {
resetCanvas();
dirtyRef.current = true;
scheduleFlush(true);
}, [resetCanvas, scheduleFlush]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[90vh] w-[96vw] max-w-[1540px] overflow-hidden rounded-[24px] border border-zinc-200/80 bg-[#f2f3f5] p-0 shadow-2xl">
<DialogHeader className="border-b border-zinc-200/80 bg-white/70 px-6 py-4 backdrop-blur">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="text-[24px] font-semibold tracking-tight text-zinc-900">
实时画布
</DialogTitle>
<DialogDescription className="mt-1 text-[13px] text-zinc-500">
左侧绘制草图,右侧实时渲染,整体风格与无限画布保持一致
</DialogDescription>
<div className="mt-2 text-[12px] text-zinc-500">{statusMessage}</div>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium",
statusBadge.className
)}
>
{status === "error" ? <WifiOff className="h-3.5 w-3.5" /> : <Wifi className="h-3.5 w-3.5" />}
{statusBadge.label}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={forceReconnect}
className="h-9 rounded-xl border-zinc-300 bg-white px-3"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
重连
</Button>
</div>
</div>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col px-5 pb-5 pt-4">
<div className="grid min-h-0 flex-1 grid-cols-2 gap-4">
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
<span className="text-[15px] font-medium text-zinc-800">草图画布</span>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="icon"
variant={brushMode === "brush" ? "default" : "outline"}
className="h-8 w-8 rounded-lg"
onClick={() => setBrushMode("brush")}
>
<Paintbrush className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant={brushMode === "eraser" ? "default" : "outline"}
className="h-8 w-8 rounded-lg"
onClick={() => setBrushMode("eraser")}
>
<Eraser className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8 rounded-lg"
onClick={clearSketch}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div ref={canvasWrapRef} className="min-h-0 flex-1 bg-[#d0d1d4] p-3">
<canvas
ref={canvasRef}
className="h-full w-full touch-none rounded-xl border border-zinc-300/80 bg-[#d0d1d4]"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
<div className="flex h-12 items-center gap-3 border-t border-zinc-200 px-4">
<Label htmlFor="rt-brush-size" className="text-[12px] font-medium text-zinc-500">
画笔粗细
</Label>
<input
id="rt-brush-size"
type="range"
min={2}
max={30}
step={1}
value={brushSize}
onChange={(event) => setBrushSize(Number(event.target.value))}
className="h-2 w-40 accent-zinc-800"
/>
<span className="text-[12px] text-zinc-500">{brushSize}px</span>
</div>
</div>
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
<span className="text-[15px] font-medium text-zinc-800">实时预览</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 rounded-lg border-zinc-300 bg-white px-3 text-[12px]"
onClick={() => scheduleFlush(true)}
>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
刷新
</Button>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden bg-[#d0d1d4] p-3">
<div className="h-full w-full overflow-hidden rounded-xl border border-zinc-300/80 bg-[#d0d1d4]">
{previewUrl ? (
<img src={previewUrl} alt="实时预览" className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-[13px] text-zinc-500">
{status === "connecting" ? (
<span className="inline-flex items-center rounded-full bg-white/70 px-3 py-1.5">
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
正在等待首帧...
</span>
) : (
<span>开始绘制后将显示实时预览</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/95 p-3 shadow-sm">
<div className="flex items-center gap-2">
<Input
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
onBlur={() => scheduleFlush(true)}
placeholder="输入提示词,例如:将草图渲染为高端工业设计风格..."
className="h-11 flex-1 rounded-xl border-zinc-300 bg-white text-[15px]"
/>
<Button
type="button"
className="h-11 rounded-xl px-5"
disabled={!previewUrl || isApplying}
onClick={uploadPreviewToCanvas}
>
{isApplying ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
发送到画布
</Button>
</div>
<div className="mt-2 text-[12px] text-zinc-500">
绘制会自动触发实时渲染,当前节流间隔 {adaptiveThrottleMs}ms。
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}