chore: sync all local updates
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-02-22 12:36:33 +08:00
parent 4993354969
commit 9d7ac81dd1
25 changed files with 4292 additions and 314 deletions

View File

@ -953,6 +953,45 @@ function buildAutoModeFallbackMessage(params: {
].join("\n");
}
function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false;
return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】)/.test(
message,
);
}
function ensureStructuredAgentFriendlyMessage(params: {
message?: string;
text: string;
hasImage: boolean;
fromHistory: boolean;
}): string {
const existingMessage = String(params.message || "").trim();
if (hasStructuredAgentSections(existingMessage)) {
return existingMessage;
}
const fallback = buildAutoModeFallbackMessage({
text: params.text,
hasImage: params.hasImage,
fromHistory: params.fromHistory,
});
if (!existingMessage) {
return fallback;
}
const normalizedSupplement = existingMessage.replace(/\s+/g, " ").trim();
if (!normalizedSupplement) {
return fallback;
}
return fallback.replace(
"### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
);
}
function extractMessageImageUrls(parts: any[]): string[] {
return (parts || [])
.map((part: any) => {
@ -1766,6 +1805,7 @@ export async function POST(request: Request) {
let smartAutoHistoryImages: string[] = []; // 从历史对话中提取的图片URL(支持多张)
let smartAutoNeedsClarification = false; // 是否需要先追问用户
let smartAutoClarificationQuestion: string | undefined;
let preferredAgentModelId: string | undefined;
const deepThinkingOn = deepThinkingEnabled !== false;
let shouldRunAgentConversation = finalModelId === "auto";
let shouldKeepSelectedModel = false;
@ -1829,8 +1869,9 @@ export async function POST(request: Request) {
} else if (isFixedVisualModel) {
shouldRunAgentConversation = true;
shouldKeepSelectedModel = true;
preferredAgentModelId = selectedModelConfig.id;
console.log(
`[Smart Auto Mode] 🎯 Agent conversation enabled for fixed visual model: ${selectedModelConfig.displayName} (${selectedModelProviderType || "unknown"})`
`[Smart Auto Mode] 🎯 Agent conversation enabled for fixed visual model: ${selectedModelConfig.displayName} (${selectedModelProviderType || "unknown"}), preferredAgentModelId=${preferredAgentModelId}`
);
}
} else if (isFixedVisualModel || isAiModelAgentAuto(selectedModelConfig)) {
@ -2002,6 +2043,7 @@ export async function POST(request: Request) {
imageFromHistory: smartAutoHistoryImages.length > 0,
historyMessages,
context: autoContextText || undefined,
preferredModelId: preferredAgentModelId,
}),
"runAutoAgentGraph",
SMART_AUTO_GRAPH_TIMEOUT_MS,
@ -2180,6 +2222,7 @@ export async function POST(request: Request) {
imageFromHistory: autoFromHistoryForFallback || smartAutoHistoryImages.length > 0,
historyMessages: [],
context: undefined,
preferredModelId: preferredAgentModelId,
}),
"fallbackAnalyzeIntent",
SMART_AUTO_FALLBACK_TIMEOUT_MS,
@ -2222,6 +2265,7 @@ export async function POST(request: Request) {
text: autoTextForFallback,
imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined,
preferredModelId: preferredAgentModelId,
}),
"fallbackVisualSummary",
SMART_AUTO_FALLBACK_TIMEOUT_MS,
@ -2357,6 +2401,23 @@ export async function POST(request: Request) {
console.log("[Smart Auto Mode] 🧩 Backfilled conversational reply with fallback");
}
if (shouldRunAgentConversation && !smartAutoNeedsClarification) {
const latestUserText = message.parts
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
const hasCurrentImageInput = message.parts.some((part: any) =>
part?.type === "file" || part?.type === "image" || part?.type === "image_url"
);
const hasHistoryImageInput = smartAutoHistoryImages.length > 0;
smartAutoFriendlyMessage = ensureStructuredAgentFriendlyMessage({
message: smartAutoFriendlyMessage,
text: latestUserText,
hasImage: hasCurrentImageInput || hasHistoryImageInput,
fromHistory: !hasCurrentImageInput && hasHistoryImageInput,
});
}
const hasUserCustomImageParams = hasUserCustomizedImageParams(requestBody.imageParams);
const hasUserCustomVideoParams = hasUserCustomizedVideoParams(requestBody.videoParams);
const shouldApplyAgentImageParams =
@ -2437,6 +2498,7 @@ export async function POST(request: Request) {
text: latestUserText,
imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined,
preferredModelId: preferredAgentModelId,
}),
"enrichImageAnalysisSummary",
timeoutMs,

View File

@ -666,7 +666,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
"absolute top-4 right-4 bottom-4 z-10 w-[460px] max-w-[calc(100vw-1rem)] transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
)}>
<div className="h-full w-full rounded-2xl border bg-background/95 backdrop-blur-md shadow-2xl overflow-hidden flex flex-col">
<div className="h-full w-full rounded-2xl border bg-background/90 backdrop-blur-md shadow-2xl overflow-hidden flex flex-col">
{panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>

View File

@ -188,7 +188,7 @@ export function AngleEditDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-4xl p-0 gap-0 overflow-hidden border-none shadow-2xl bg-background/95 backdrop-blur-xl"
className="max-w-4xl p-0 gap-0 overflow-hidden border-none shadow-2xl bg-background/90 backdrop-blur-xl"
onPointerDownCapture={stopDialogEventCapture}
onPointerMoveCapture={stopDialogEventCapture}
onPointerUpCapture={stopDialogEventCapture}

View File

@ -99,7 +99,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden bg-background/95 backdrop-blur-md">
<DialogContent className="max-w-md max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden bg-background/90 backdrop-blur-md">
<DialogHeader className="p-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle>已生成文件列表</DialogTitle>

View File

@ -27,21 +27,14 @@ import {
Paintbrush,
Square,
Circle as CircleIcon,
Slash,
Upload,
RefreshCw,
Send,
Sparkles,
Trash2,
Wifi,
WifiOff,
Minimize2,
Maximize2,
Download,
Image as ImageIcon,
MoreHorizontal,
Undo2,
Redo2,
X,
} from "lucide-react";
import { toast } from "sonner";
@ -76,6 +69,15 @@ type RealtimeCanvasDialogProps = {
const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。";
const CANVAS_BACKGROUND = "#ffffff";
const BRUSH_COLOR_PRESETS = [
"#18181b",
"#ef4444",
"#f59e0b",
"#22c55e",
"#3b82f6",
"#a855f7",
] as const;
const CANVAS_BACKGROUND_PRESETS = ["#ffffff", "#f4f4f5", "#fdf2f8", "#ecfeff", "#f0fdf4"] as const;
const defaultRealtimeConfig: RealtimeConfig = {
transport: {
@ -112,14 +114,15 @@ function buildImageDataUrl(image: any) {
return "";
}
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle") {
return mode === "rect" || mode === "circle";
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle" | "line") {
return mode === "rect" || mode === "circle" || mode === "line";
}
async function canvasToJpegDataUri(
canvas: HTMLCanvasElement,
maxInputResolution: number,
jpegQuality: number
jpegQuality: number,
backgroundColor: string
) {
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
@ -136,7 +139,7 @@ async function canvasToJpegDataUri(
throw new Error("无法初始化导出画布");
}
exportCtx.fillStyle = CANVAS_BACKGROUND;
exportCtx.fillStyle = backgroundColor;
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
@ -159,15 +162,14 @@ export function RealtimeCanvasDialog({
open,
onOpenChange,
initialImageUrl,
onApplyResult,
}: RealtimeCanvasDialogProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const previewCanvasRef = 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 shapeStartPointRef = useRef<{ x: number; y: number } | null>(null);
const shapeSnapshotRef = useRef<ImageData | null>(null);
const pendingFlushTimerRef = useRef<number | null>(null);
const flushInFlightRef = useRef(false);
const dirtyRef = useRef(false);
@ -178,6 +180,7 @@ export function RealtimeCanvasDialog({
const metricsQueueRef = useRef<any[]>([]);
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const hasLoadedInitialImageRef = useRef(false);
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
// History Management
const historyRef = useRef<ImageData[]>([]);
@ -192,8 +195,9 @@ export function RealtimeCanvasDialog({
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" | "rect" | "circle">("brush");
const [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle" | "line">("brush");
const [brushColor, setBrushColor] = useState<string>(BRUSH_COLOR_PRESETS[0]);
const [canvasBackground, setCanvasBackground] = useState<string>(canvasBackgroundRef.current);
const [brushSize, setBrushSize] = useState(6);
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval
@ -244,9 +248,14 @@ export function RealtimeCanvasDialog({
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
}
try {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
historyRef.current.push(imageData);
historyIndexRef.current = historyRef.current.length - 1;
} catch (error) {
console.warn("[RealtimeCanvas] saveHistory skipped:", error);
return;
}
// Limit history size
if (historyRef.current.length > 50) {
@ -279,7 +288,8 @@ export function RealtimeCanvasDialog({
const imageDataUri = await canvasToJpegDataUri(
canvas,
config.image.maxInputResolution,
config.image.jpegQuality
config.image.jpegQuality,
canvasBackgroundRef.current
);
dirtyRef.current = false;
@ -334,6 +344,14 @@ export function RealtimeCanvasDialog({
[adaptiveThrottleMs, flushFrame]
);
const clearPreviewCanvas = useCallback(() => {
const previewCanvas = previewCanvasRef.current;
if (!previewCanvas) return;
const previewCtx = previewCanvas.getContext("2d");
if (!previewCtx) return;
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
}, []);
const undo = useCallback(() => {
if (historyIndexRef.current <= 0) return;
@ -373,7 +391,7 @@ export function RealtimeCanvasDialog({
if (!ctx) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = CANVAS_BACKGROUND;
ctx.fillStyle = canvasBackgroundRef.current;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
@ -412,18 +430,28 @@ export function RealtimeCanvasDialog({
const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const previewCanvas = previewCanvasRef.current;
const wrap = canvasWrapRef.current;
if (!canvas || !wrap) return;
if (!canvas || !previewCanvas || !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`;
previewCanvas.width = canvas.width;
previewCanvas.height = canvas.height;
previewCanvas.style.width = canvas.style.width;
previewCanvas.style.height = canvas.style.height;
const ctx = canvas.getContext("2d");
const previewCtx = previewCanvas.getContext("2d");
if (!ctx) return;
if (!previewCtx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
previewCtx.setTransform(1, 0, 0, 1, 0, 0);
previewCtx.scale(dpr, dpr);
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
resetCanvas();
hasLoadedInitialImageRef.current = false;
}, [resetCanvas]);
@ -452,7 +480,7 @@ export function RealtimeCanvasDialog({
ctx.lineWidth = brushSize * 1.45;
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#18181b";
ctx.strokeStyle = brushColor;
ctx.lineWidth = brushSize;
}
ctx.lineCap = "round";
@ -463,14 +491,17 @@ export function RealtimeCanvasDialog({
ctx.stroke();
ctx.restore();
},
[brushMode, brushSize]
[brushMode, brushColor, brushSize]
);
const drawShape = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
(
from: { x: number; y: number },
to: { x: number; y: number },
targetCtx?: CanvasRenderingContext2D | null
) => {
const defaultCtx = canvasRef.current?.getContext("2d");
const ctx = targetCtx || defaultCtx;
if (!ctx) return;
const minX = Math.min(from.x, to.x);
@ -480,7 +511,7 @@ export function RealtimeCanvasDialog({
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#18181b";
ctx.strokeStyle = brushColor;
ctx.lineWidth = Math.max(1, brushSize * 0.9);
ctx.lineCap = "round";
ctx.lineJoin = "round";
@ -495,28 +526,34 @@ export function RealtimeCanvasDialog({
ctx.beginPath();
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (brushMode === "line") {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
ctx.restore();
},
[brushMode, brushSize]
[brushMode, brushColor, brushSize]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
const point = getCanvasPoint(event);
if (!point) return;
pointerDownRef.current = true;
lastPointRef.current = point;
if (isShapeMode(brushMode)) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d", { willReadFrequently: true });
shapeStartPointRef.current = point;
shapeSnapshotRef.current =
canvas && ctx ? ctx.getImageData(0, 0, canvas.width, canvas.height) : null;
clearPreviewCanvas();
} else {
drawStroke(point, point);
scheduleFlush();
}
event.currentTarget.setPointerCapture(event.pointerId);
},
[brushMode, getCanvasPoint]
[brushMode, clearPreviewCanvas, drawStroke, getCanvasPoint, scheduleFlush]
);
const handlePointerLeave = useCallback(() => {
@ -527,11 +564,11 @@ export function RealtimeCanvasDialog({
pointerDownRef.current = false;
lastPointRef.current = null;
shapeStartPointRef.current = null;
shapeSnapshotRef.current = null;
clearPreviewCanvas();
if (wasDrawing) {
scheduleFlush(true, false);
}
}, [scheduleFlush]);
}, [clearPreviewCanvas, scheduleFlush]);
const handlePointerEnter = useCallback(() => {
if (cursorRef.current) {
@ -541,6 +578,7 @@ export function RealtimeCanvasDialog({
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
// Update cursor
const point = getCanvasPoint(event);
if (point && cursorRef.current) {
@ -558,11 +596,11 @@ export function RealtimeCanvasDialog({
if (!pointerDownRef.current) return;
if (!point || !lastPointRef.current) return;
if (isShapeMode(brushMode) && shapeStartPointRef.current) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx && shapeSnapshotRef.current) {
ctx.putImageData(shapeSnapshotRef.current, 0, 0);
drawShape(shapeStartPointRef.current, point);
const previewCanvas = previewCanvasRef.current;
const previewCtx = previewCanvas?.getContext("2d");
if (previewCanvas && previewCtx) {
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
drawShape(shapeStartPointRef.current, point, previewCtx);
}
} else {
drawStroke(lastPointRef.current, point);
@ -575,20 +613,16 @@ export function RealtimeCanvasDialog({
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
pointerDownRef.current = false;
const endPoint = getCanvasPoint(event);
const shapeStart = shapeStartPointRef.current;
if (isShapeMode(brushMode) && endPoint && shapeStart) {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx && shapeSnapshotRef.current) {
ctx.putImageData(shapeSnapshotRef.current, 0, 0);
drawShape(shapeStart, endPoint);
}
clearPreviewCanvas();
}
lastPointRef.current = null;
shapeStartPointRef.current = null;
shapeSnapshotRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
@ -597,9 +631,13 @@ export function RealtimeCanvasDialog({
saveHistory(); // Save state after stroke
scheduleFlush(true, false);
},
[brushMode, drawShape, getCanvasPoint, saveHistory, scheduleFlush]
[brushMode, clearPreviewCanvas, drawShape, getCanvasPoint, saveHistory, scheduleFlush]
);
useEffect(() => {
clearPreviewCanvas();
}, [brushMode, clearPreviewCanvas]);
// Shortcuts
useEffect(() => {
if (!open) return;
@ -625,43 +663,6 @@ export function RealtimeCanvasDialog({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, undo, redo]);
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();
@ -848,9 +849,10 @@ export function RealtimeCanvasDialog({
const clearSketch = useCallback(() => {
resetCanvas();
clearPreviewCanvas();
dirtyRef.current = true;
scheduleFlush(true, false);
}, [resetCanvas, scheduleFlush]);
}, [clearPreviewCanvas, resetCanvas, scheduleFlush]);
const handleSketchUpload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
@ -901,15 +903,59 @@ export function RealtimeCanvasDialog({
[saveHistory, scheduleFlush]
);
const handleCanvasBackgroundChange = useCallback(
(nextColor: string) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const snapshot = document.createElement("canvas");
snapshot.width = canvas.width;
snapshot.height = canvas.height;
const snapshotCtx = snapshot.getContext("2d");
if (!snapshotCtx) return;
snapshotCtx.drawImage(canvas, 0, 0);
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = nextColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(snapshot, 0, 0);
ctx.restore();
canvasBackgroundRef.current = nextColor;
setCanvasBackground(nextColor);
saveHistory();
scheduleFlush(true);
},
[saveHistory, scheduleFlush]
);
const handleDownloadCanvas = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas) return;
try {
const dataUrl = await canvasToJpegDataUri(canvas, 2048, 0.92, canvasBackgroundRef.current);
const anchor = document.createElement("a");
anchor.href = dataUrl;
anchor.download = `realtime-canvas-${Date.now()}.jpg`;
anchor.click();
toast.success("已保存当前画布");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
<DialogHeader className="sr-only">
<DialogTitle>实时画布</DialogTitle>
<DialogDescription>左侧绘制草图,右侧实时预览,并可将结果发送到无限画布。</DialogDescription>
<DialogDescription>左侧绘制草图,右侧实时预览。</DialogDescription>
</DialogHeader>
{/* Top Header - Minimal */}
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-between px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/80 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
<span className="font-semibold text-sm">Realtime Canvas</span>
<div className="h-4 w-[1px] bg-border" />
@ -918,17 +964,6 @@ export function RealtimeCanvasDialog({
{statusBadge.label}
</span>
</div>
<div className="pointer-events-auto flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-background/50 hover:bg-background/80 backdrop-blur-md text-foreground/80 hover:text-foreground transition-colors"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Main Canvas Area - Split View */}
@ -952,6 +987,10 @@ export function RealtimeCanvasDialog({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
<canvas
ref={previewCanvasRef}
className="pointer-events-none absolute inset-0 touch-none"
/>
{/* Custom Brush Cursor */}
<div
ref={cursorRef}
@ -997,102 +1036,198 @@ export function RealtimeCanvasDialog({
</div>
</div>
{/* Floating Toolbar - Left (Tools) */}
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2 p-2 bg-background/90 backdrop-blur-xl border border-border/50 shadow-xl rounded-2xl">
{/* Floating Toolbar - Top (Lovart-like) */}
<div className="absolute left-1/2 top-[72px] z-20 -translate-x-1/2 pointer-events-none">
<div className="pointer-events-auto flex items-center gap-1 rounded-[30px] border border-black/10 bg-white/90 p-2 shadow-xl backdrop-blur-xl">
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={undo}
disabled={!canUndo}
title="Undo (Cmd+Z)"
>
<Undo2 className="h-5 w-5 opacity-70" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={redo}
disabled={!canRedo}
title="Redo (Cmd+Shift+Z)"
>
<Redo2 className="h-5 w-5 opacity-70" />
</Button>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<Button
size="icon"
variant={brushMode === "brush" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "brush" && "bg-primary text-primary-foreground shadow-sm")}
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "brush" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("brush")}
title="Brush"
title="画笔"
>
<Paintbrush className="h-5 w-5" />
</Button>
<Button
size="icon"
variant={brushMode === "eraser" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")}
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "eraser" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("eraser")}
title="Eraser"
title="橡皮"
>
<Eraser className="h-5 w-5" />
</Button>
<Button
size="icon"
variant={brushMode === "rect" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "rect" && "bg-primary text-primary-foreground shadow-sm")}
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "rect" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("rect")}
title="Rectangle"
title="矩形"
>
<Square className="h-5 w-5" />
</Button>
<Button
size="icon"
variant={brushMode === "circle" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "circle" && "bg-primary text-primary-foreground shadow-sm")}
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "circle" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("circle")}
title="Circle"
title="圆形"
>
<CircleIcon className="h-5 w-5" />
</Button>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "line" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("line")}
title="直线"
>
<Slash className="h-5 w-5" />
</Button>
<div className="flex flex-col items-center gap-2 py-2">
<span className="text-[10px] font-medium text-muted-foreground">{brushSize}px</span>
<div className="mx-1 h-7 w-px bg-black/10" />
<div className="flex items-center gap-1 px-1">
{BRUSH_COLOR_PRESETS.map((color) => (
<button
key={color}
type="button"
className={cn(
"h-5 w-5 rounded-full border border-black/15 transition-all",
brushColor === color && "scale-110 ring-2 ring-black/25"
)}
style={{ backgroundColor: color }}
onClick={() => setBrushColor(color)}
title={color}
/>
))}
<input
type="color"
value={brushColor}
onChange={(event) => setBrushColor(event.target.value)}
className="h-5 w-5 cursor-pointer rounded-full border border-black/15 bg-transparent p-0"
title="自定义颜色"
/>
</div>
<div className="mx-1 h-7 w-px bg-black/10" />
<div className="w-[88px] px-1">
<Slider
orientation="vertical"
value={[brushSize]}
min={2}
max={50}
step={1}
onValueChange={([val]) => setBrushSize(val)}
className="h-24 w-4"
/>
</div>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<div className="mx-1 h-7 w-px bg-black/10" />
<div className="flex items-center gap-1">
{CANVAS_BACKGROUND_PRESETS.map((color) => (
<button
key={color}
type="button"
className={cn(
"h-4 w-4 rounded border border-black/15 transition-all",
canvasBackground === color && "scale-110 ring-2 ring-black/20"
)}
style={{ backgroundColor: color }}
onClick={() => handleCanvasBackgroundChange(color)}
title="画布底色"
/>
))}
</div>
<div className="mx-1 h-7 w-px bg-black/10" />
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={() => uploadInputRef.current?.click()}
title="上传图片"
>
<Upload className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={clearSketch}
title="Clear"
title="清空"
>
<Trash2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={() => uploadInputRef.current?.click()}
title="Upload Image"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={undo}
disabled={!canUndo}
title="撤销"
>
<Upload className="h-5 w-5" />
<Undo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={redo}
disabled={!canRedo}
title="重做"
>
<Redo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={() => scheduleFlush(true)}
disabled={status === "connecting"}
title="生成"
>
{status === "connecting" ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Sparkles className="h-5 w-5" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={handleDownloadCanvas}
title="保存"
>
<Download className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={forceReconnect}
title="重连"
>
<RefreshCw className="h-5 w-5" />
</Button>
</div>
<input
ref={uploadInputRef}
type="file"
@ -1101,7 +1236,6 @@ export function RealtimeCanvasDialog({
onChange={handleSketchUpload}
/>
</div>
</div>
{/* Floating Toolbar - Bottom (Prompt) */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20 pointer-events-none">
@ -1134,28 +1268,6 @@ export function RealtimeCanvasDialog({
</div>
</div>
{/* Floating Toolbar - Right (Actions) */}
<div className="absolute right-6 top-6 flex flex-col gap-3 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2">
<Button
className="h-10 px-4 rounded-full shadow-lg bg-primary text-primary-foreground hover:bg-primary/90"
onClick={uploadPreviewToCanvas}
disabled={!previewUrl || isApplying}
>
{isApplying ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Send to Canvas
</Button>
<Button
variant="secondary"
className="h-10 px-4 rounded-full shadow-md bg-background/80 backdrop-blur-md hover:bg-background"
onClick={forceReconnect}
>
<RefreshCw className="mr-2 h-4 w-4" />
Reconnect
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);

View File

@ -1906,7 +1906,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] w-[360px] rounded-xl border border-border bg-background/95 backdrop-blur shadow-2xl"
className="fixed z-[9999] w-[360px] rounded-xl border border-border bg-background/90 backdrop-blur shadow-2xl"
style={{
top: getTextPanelPosition().top,
left: getTextPanelPosition().left,

View File

@ -1686,7 +1686,7 @@ export function LovartHome({
</span>
</div>
</SelectTrigger>
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6]/90 p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{allComposerModels.map((model) => (
<SelectItem
@ -2105,7 +2105,7 @@ export function LovartHome({
) : null}
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
<DialogContent className="max-h-[88vh] w-[92vw] max-w-[980px] overflow-hidden rounded-[22px] border-black/10 bg-[#f6f6f7] p-0">
<DialogContent className="max-h-[88vh] w-[92vw] max-w-[980px] overflow-hidden rounded-[22px] border-black/10 bg-[#f6f6f7]/90 p-0">
<DialogHeader className="sr-only">
<DialogTitle>账户管理</DialogTitle>
</DialogHeader>
@ -2264,7 +2264,7 @@ export function LovartHome({
</Dialog>
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[980px] overflow-hidden rounded-2xl border-black/10 bg-[#f7f7f7] p-0">
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[980px] overflow-hidden rounded-2xl border-black/10 bg-[#f7f7f7]/90 p-0">
<div className="flex h-full max-h-[90vh] flex-col">
<DialogHeader className="border-b border-black/6 px-5 py-4">
<DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle>

View File

@ -8,7 +8,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, SlidersHorizontal } from "lucide-react";
import { Check, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@ -72,6 +72,12 @@ const IMAGE_SIZE_OPTIONS = [
{ value: "4K", label: "4K", disabled: false },
] as const;
const IMAGE_SIZE_PIXEL_MAP: Record<string, number> = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
};
export function ImageParamsBar({
modelName = "Gemini 图片生成",
initialParams,
@ -145,6 +151,58 @@ export function ImageParamsBar({
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
const size = IMAGE_SIZE_PIXEL_MAP[imageSize] || IMAGE_SIZE_PIXEL_MAP["2K"];
if (aspectRatio === "auto" || aspectRatio === "1:1") {
return { width: size, height: size };
}
const [wRaw, hRaw] = aspectRatio.split(":").map((item) => Number(item));
if (!wRaw || !hRaw) {
return { width: size, height: size };
}
if (wRaw >= hRaw) {
return {
width: size,
height: Math.max(1, Math.round((size * hRaw) / wRaw)),
};
}
return {
width: Math.max(1, Math.round((size * wRaw) / hRaw)),
height: size,
};
};
const renderRatioGlyph = (ratioValue: string) => {
if (ratioValue === "auto") {
return <Scan className="h-4 w-4" />;
}
const [wRaw, hRaw] = ratioValue.split(":").map((item) => Number(item));
if (!wRaw || !hRaw) {
return <Scan className="h-4 w-4" />;
}
const maxSide = 22;
let width = maxSide;
let height = maxSide;
if (wRaw >= hRaw) {
width = maxSide;
height = Math.max(8, Math.round((maxSide * hRaw) / wRaw));
} else {
height = maxSide;
width = Math.max(8, Math.round((maxSide * wRaw) / hRaw));
}
return (
<span
className="inline-block rounded-[6px] border-2 border-current"
style={{ width, height }}
/>
);
};
// 🆕 监听 initialParams 变化,确保状态同步
useEffect(() => {
console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams);
@ -176,71 +234,119 @@ export function ImageParamsBar({
// 内联模式:只返回参数按钮,嵌入工具栏
if (inline) {
const ratioLabel = params.aspectRatio === "auto" ? "智能比例" : params.aspectRatio;
const sizeLabel = params.imageSize === "4K" ? "超清 4K" : `高清 ${params.imageSize}`;
const outputSize = getComputedOutputSize(params.aspectRatio, params.imageSize);
return (
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
{/* 图片比例 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-7 min-w-[46px] rounded-md border border-[#d5dae2] bg-white px-2 text-[11px] font-medium text-[#374151] hover:bg-[#f7f8fa] focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
title={`比例:${params.aspectRatio}`}
className="h-9 rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-3 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90"
title={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
>
{params.aspectRatio === "auto" ? "自动" : params.aspectRatio}
<span className="sr-only">{`比例:${params.aspectRatio}`}</span>
<span className="mr-1.5 flex h-4 w-4 items-center justify-center text-[#20262f]">
{renderRatioGlyph(params.aspectRatio)}
</span>
<span>{ratioLabel}</span>
<span className="mx-2 h-3.5 w-px bg-[#cfd5de]" />
<span className="inline-flex items-center gap-1">
{sizeLabel}
{params.imageSize === "4K" ? <Sparkles className="h-3.5 w-3.5 text-[#0ea5e9]" /> : null}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
<div className="p-1.5 grid grid-cols-3 gap-1">
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={10}
className="w-[620px] max-w-[calc(100vw-32px)] rounded-[24px] border border-[#d8dde4] bg-[#f3f4f6]/90 p-4 shadow-[0_14px_36px_rgba(15,23,42,0.16)]"
>
<div className="space-y-5">
<div>
<p className="text-[13px] font-medium text-[#8a94a3]">选择比例</p>
<div className="mt-2 grid grid-cols-3 gap-2 rounded-[20px] bg-[#eceff2]/90 p-2 md:grid-cols-9">
{ASPECT_RATIO_OPTIONS.map((option) => (
<Button
<button
key={option.value}
variant={params.aspectRatio === option.value ? "secondary" : "ghost"}
size="sm"
className="h-7 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
type="button"
className={cn(
"flex h-[92px] flex-col items-center justify-center rounded-[16px] border text-[#5f6b7a] transition",
params.aspectRatio === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_2px_8px_rgba(15,23,42,0.08)]"
: "border-transparent hover:bg-white/70"
)}
onClick={() => updateParam("aspectRatio", option.value)}
>
{option.label}
</Button>
<span className="mb-2 flex h-6 items-center justify-center">
{renderRatioGlyph(option.value)}
</span>
<span className="text-[15px] font-semibold">
{option.value === "auto" ? "智能" : option.label}
</span>
</button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 分辨率 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-7 min-w-[42px] rounded-md border border-[#d5dae2] bg-white px-2 text-[11px] font-medium text-[#374151] hover:bg-[#f7f8fa] focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
title={`分辨率:${params.imageSize}`}
>
{params.imageSize}
<span className="sr-only">{`分辨率:${params.imageSize}`}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100px]">
{IMAGE_SIZE_OPTIONS.map((option) => (
<DropdownMenuItem
<div>
<p className="text-[13px] font-medium text-[#8a94a3]">选择分辨率</p>
<div className="mt-2 grid grid-cols-2 gap-2 rounded-[18px] bg-[#eceff2]/90 p-1.5">
{IMAGE_SIZE_OPTIONS.filter((option) => !option.disabled).map((option) => (
<button
key={option.value}
type="button"
className={cn(
"h-16 rounded-[14px] text-[18px] font-semibold transition md:text-[20px]",
params.imageSize === option.value
? "bg-white text-[#111827] shadow-[0_2px_8px_rgba(15,23,42,0.08)]"
: "bg-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("imageSize", option.value)}
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
>
<Check className={cn("mr-2 h-4 w-4", params.imageSize === option.value ? "opacity-100" : "opacity-0")} />
{option.label}
</DropdownMenuItem>
<span className="inline-flex items-center gap-1">
{option.value === "4K" ? "超清 4K" : `高清 ${option.label}`}
{option.value === "4K" ? <Sparkles className="h-4 w-4 text-[#0ea5e9]" /> : null}
</span>
</button>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div>
<p className="text-[13px] font-medium text-[#8a94a3]">尺寸</p>
<div className="mt-2 grid grid-cols-[1fr_auto_1fr_auto] items-center gap-2">
<div className="h-16 rounded-[14px] border border-[#dce1e8] bg-[#eef1f4]/90 px-3 text-[#b0b8c4]">
<div className="flex h-full items-center justify-between">
<span className="text-[16px] font-semibold">W</span>
<span className="text-[30px] font-semibold">{outputSize.width}</span>
</div>
</div>
<Link2 className="h-5 w-5 text-[#adb6c3]" />
<div className="h-16 rounded-[14px] border border-[#dce1e8] bg-[#eef1f4]/90 px-3 text-[#b0b8c4]">
<div className="flex h-full items-center justify-between">
<span className="text-[16px] font-semibold">H</span>
<span className="text-[30px] font-semibold">{outputSize.height}</span>
</div>
</div>
<span className="text-[26px] font-semibold text-[#657180]">PX</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{showImageCountSelector && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-7 min-w-[42px] rounded-md border border-[#d5dae2] bg-white px-2 text-[11px] font-medium text-[#374151] hover:bg-[#f7f8fa] focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
className="h-9 min-w-[56px] rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2.5 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`数量:${params.imageCount || 1}张`}
>
<Sparkles className="mr-1 h-3.5 w-3.5 text-[#6b7280]" />
{params.imageCount || 1}张
<span className="sr-only">{`数量:${params.imageCount || 1}张`}</span>
</Button>
@ -267,7 +373,7 @@ export function ImageParamsBar({
<Button
variant="ghost"
className={cn(
"h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0",
"h-9 w-9 rounded-[14px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0",
(params.promptExtend || params.negativePrompt) && "text-primary"
)}
title="高级设置"

View File

@ -1567,7 +1567,7 @@ function PureModelSelectorCompact({
</Button>
)}
</Trigger>
<PromptInputModelSelectContent className="!w-[266px] max-h-[372px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5] p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
<PromptInputModelSelectContent className="!w-[266px] max-h-[372px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
<div className="max-h-[338px] space-y-0.5 overflow-y-auto pr-1">
{displayModels.map((model) => (
<SelectItem

View File

@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background/90 p-6 shadow-lg backdrop-blur-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}

View File

@ -39,7 +39,7 @@ const DialogContent = forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[9999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-[9999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background/90 p-6 shadow-lg backdrop-blur-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
@ -125,4 +125,3 @@ export {
DialogTitle,
DialogDescription,
};

View File

@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover/90 p-1 text-popover-foreground shadow-lg backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover/90 p-1 text-popover-foreground shadow-md backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}

View File

@ -18,7 +18,7 @@ const HoverCardContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
"z-50 w-64 rounded-md border bg-popover/90 p-4 text-popover-foreground shadow-md backdrop-blur-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}

View File

@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 w-72 rounded-md border bg-popover/90 p-4 text-popover-foreground shadow-md backdrop-blur-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@ -31,4 +31,3 @@ const PopoverContent = React.forwardRef<
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-[10000] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-[10000] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover/90 text-popover-foreground shadow-md backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className

View File

@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"fixed z-50 gap-4 bg-background/90 p-6 shadow-lg backdrop-blur-md transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {

View File

@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
"z-50 overflow-hidden rounded-md border bg-popover/90 px-3 py-1.5 text-sm text-popover-foreground shadow-md backdrop-blur-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}

View File

@ -501,7 +501,7 @@ const getImageUploadConfig = () => {
}
return (
<div className="bg-background/95 backdrop-blur-sm border-t-0 p-2">
<div className="bg-background/90 backdrop-blur-sm border-t-0 p-2">
{/* 🆕 图片上传区域(根据模式动态显示) */}
{imageConfig.max > 0 && (
<div className="pb-2 flex justify-center">

View File

@ -51,6 +51,7 @@ export interface AutoAgentInput {
imageFromHistory?: boolean;
historyMessages?: HistoryMessage[];
context?: string;
preferredModelId?: string;
}
export interface AutoAgentResult {
@ -69,6 +70,7 @@ const AutoAgentState = Annotation.Root({
imageFromHistory: Annotation<boolean>,
historyMessages: Annotation<HistoryMessage[]>,
context: Annotation<string | null>,
preferredModelId: Annotation<string | null>,
analysis: Annotation<AnalysisResult | null>,
friendlyMessage: Annotation<string | null>,
optimizedPrompt: Annotation<string | null>,
@ -623,12 +625,33 @@ function pickAgentVisionModel(
return pickQwenVisionModel(models);
}
function resolvePreferredModel(
preferredModelId: string | null | undefined,
models: EnabledModelWithProvider[],
) {
const normalizedId = String(preferredModelId || "").trim();
if (!normalizedId) return undefined;
return models.find(
(item) => item.model.id === normalizedId || item.model.modelId === normalizedId,
);
}
function resolvePrimaryAgentModel(params: {
preferredModelId?: string | null;
configuredAgentModel?: EnabledModelWithProvider | null;
allModels: EnabledModelWithProvider[];
}) {
const preferred = resolvePreferredModel(params.preferredModelId, params.allModels);
if (preferred) return preferred;
return params.configuredAgentModel || undefined;
}
function pickAgentReasoningModel(
agentModel: EnabledModelWithProvider | undefined,
models: EnabledModelWithProvider[],
) {
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
if (
agentModel &&
@ -677,7 +700,17 @@ async function generateAgentConversation(
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const reasoningModelConfig = pickAgentReasoningModel(agentModel, allModels);
const primaryAgentModel = resolvePrimaryAgentModel({
preferredModelId: state.preferredModelId,
configuredAgentModel: agentModel,
allModels,
});
if (primaryAgentModel && state.preferredModelId) {
console.log(
`[Auto Agent] Preferred analysis model: ${primaryAgentModel.model.displayName} (${primaryAgentModel.provider.type})`,
);
}
const reasoningModelConfig = pickAgentReasoningModel(primaryAgentModel, allModels);
if (!reasoningModelConfig) {
return analysis.conversationalReply || null;
}
@ -702,7 +735,7 @@ async function generateAgentConversation(
let visualAnalysis = "";
if (state.hasImage && validImageUrls.length > 0) {
try {
const visionModelConfig = pickAgentVisionModel(agentModel, allModels);
const visionModelConfig = pickAgentVisionModel(primaryAgentModel, allModels);
if (!visionModelConfig) {
throw new Error("missing_vision_model");
}
@ -816,7 +849,12 @@ async function recommendImageParamsWithAgent(
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const plannerModelConfig = pickAgentReasoningModel(agentModel, allModels);
const primaryAgentModel = resolvePrimaryAgentModel({
preferredModelId: state.preferredModelId,
configuredAgentModel: agentModel,
allModels,
});
const plannerModelConfig = pickAgentReasoningModel(primaryAgentModel, allModels);
if (!plannerModelConfig) {
return heuristicImageParams(state.text, analysis);
}
@ -886,7 +924,12 @@ async function recommendVideoPlanWithAgent(
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const plannerModelConfig = pickAgentReasoningModel(agentModel, allModels);
const primaryAgentModel = resolvePrimaryAgentModel({
preferredModelId: state.preferredModelId,
configuredAgentModel: agentModel,
allModels,
});
const plannerModelConfig = pickAgentReasoningModel(primaryAgentModel, allModels);
if (!plannerModelConfig) {
return heuristicVideoPlan(state);
}
@ -991,7 +1034,12 @@ async function refineRouteWithAgent(
if (imageCandidates.length <= 1) return baseRoute;
const agentModel = await getAgentAiModelWithProvider();
const plannerModelConfig = pickAgentReasoningModel(agentModel, allModelsRaw);
const primaryAgentModel = resolvePrimaryAgentModel({
preferredModelId: state.preferredModelId,
configuredAgentModel: agentModel,
allModels: allModelsRaw,
});
const plannerModelConfig = pickAgentReasoningModel(primaryAgentModel, allModelsRaw);
if (!plannerModelConfig) return baseRoute;
const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
if (!plannerModel) return baseRoute;
@ -1044,6 +1092,7 @@ const analyzeNode = async (state: AutoAgentStateType) => {
imageFromHistory: state.imageFromHistory,
historyMessages: state.historyMessages,
context: state.context ?? undefined,
preferredModelId: state.preferredModelId ?? undefined,
});
const fallbackQuestion = analysis.needsClarification
@ -1193,6 +1242,7 @@ export async function runAutoAgentGraph(params: AutoAgentInput): Promise<AutoAge
imageFromHistory: params.imageFromHistory ?? false,
historyMessages: params.historyMessages ?? [],
context: params.context ?? null,
preferredModelId: params.preferredModelId ?? null,
analysis: null,
friendlyMessage: null,
optimizedPrompt: null,

View File

@ -550,7 +550,7 @@ export async function optimizePromptWithLLM(params: {
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
const usableAgentModel =
agentModel && isReasoningCompatibleProvider(agentModel.provider.type)
? agentModel

View File

@ -866,8 +866,9 @@ export async function summarizeImageForExecutionReport(params: {
text?: string;
imageUrls?: string[];
context?: string;
preferredModelId?: string;
}): Promise<string | null> {
const { text, imageUrls, context } = params;
const { text, imageUrls, context, preferredModelId } = params;
const visionImages = await buildAnalyzerVisionImages(imageUrls);
if (visionImages.length === 0) return null;
@ -876,7 +877,15 @@ export async function summarizeImageForExecutionReport(params: {
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const configuredAgentModel = await getAgentAiModelWithProvider();
const normalizedPreferredId = String(preferredModelId || "").trim();
const preferredModel =
normalizedPreferredId.length > 0
? allModels.find(
(item) => item.model.id === normalizedPreferredId || item.model.modelId === normalizedPreferredId,
)
: undefined;
const agentModel = preferredModel || configuredAgentModel;
const zhipuProviderFromAgent =
agentModel?.provider?.apiKey && isLikelyZhipuEndpoint(agentModel.provider.baseUrl)
? agentModel.provider
@ -1159,6 +1168,9 @@ function shouldForceQwen3VlForImageAnalysis(modelWithProvider?: {
type?: string | null;
};
} | null): boolean {
if (process.env.AGENT_FORCE_QWEN3VL_FALLBACK !== "1") {
return false;
}
if (!modelWithProvider) return false;
if (modelWithProvider.model.supportsVision) return false;
if (!isAiModelMultimodal(modelWithProvider.model)) return false;
@ -1182,8 +1194,9 @@ export async function analyzeIntent(params: {
imageFromHistory?: boolean; // 🆕 图片是否来自历史记录
historyMessages?: HistoryMessage[]; // 🆕 历史对话消息(用于上下文理解)
context?: string; // 🆕 画布上下文(仅用于理解)
preferredModelId?: string;
}): Promise<AnalysisResult> {
const { text, hasImage, imageUrls, imageFromHistory, historyMessages, context } = params;
const { text, hasImage, imageUrls, imageFromHistory, historyMessages, context, preferredModelId } = params;
console.log("[Smart Analyzer] Analyzing intent:", {
text,
@ -1202,7 +1215,14 @@ export async function analyzeIntent(params: {
// 尝试使用多模态模型进行深度分析(传入历史对话)
try {
const llmResult = await analyzWithLLM(text, hasImage, imageUrls, historyMessages, context);
const llmResult = await analyzWithLLM(
text,
hasImage,
imageUrls,
historyMessages,
context,
preferredModelId,
);
if (llmResult) {
console.log("[Smart Analyzer] LLM analysis result:", llmResult);
return applyDirectExecutionPolicy(llmResult, { text, hasImage, imageUrls, imageFromHistory });
@ -1238,7 +1258,8 @@ async function analyzWithLLM(
hasImage: boolean,
imageUrls?: string[],
historyMessages?: HistoryMessage[],
context?: string
context?: string,
preferredModelId?: string
): Promise<AnalysisResult | null> {
// 使用动态导入避免循环依赖问题
const {
@ -1251,9 +1272,17 @@ async function analyzWithLLM(
// 1) 若 Agent 模型被标记为「多模态」,且当前请求包含图片,则识图+分析统一走该 Agent 模型
// 2) 否则按既有视觉模型优先级选择
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const configuredAgentModel = await getAgentAiModelWithProvider();
const normalizedPreferredId = String(preferredModelId || "").trim();
const preferredModel =
normalizedPreferredId.length > 0
? allModels.find(
(item) => item.model.id === normalizedPreferredId || item.model.modelId === normalizedPreferredId,
)
: undefined;
const agentModel = preferredModel || configuredAgentModel;
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
const isLikelyZhipuVisionModel = (displayName: string, modelId: string, providerType: string) =>
providerType === "zhipu" ||
/(glm[-_.]?4v|glm[-_.]?4\.?1v|glm[-_.]?4\.?5v|vision)/i.test(
@ -1296,6 +1325,12 @@ async function analyzWithLLM(
allModels.find((m) => m.provider.type === "zhipu" && !m.model.isSubModel) ||
preferredVisionModel;
if (preferredModel && selectedAnalyzerModel?.model.id === preferredModel.model.id) {
console.log(
`[Smart Analyzer] Preferred analyzer model selected: ${preferredModel.model.displayName} (${preferredModel.provider.type})`,
);
}
if (!selectedAnalyzerModel) {
console.log("[Smart Analyzer] No available analyzer model found");
return analyzeWithHardcodedQwen3VlFallback({

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,176 @@
# fal 实时生图前后端接口契约(V1)
> 更新日期:2026-02-19
> 目的:统一前端、后端、测试对接口和状态码的理解
---
## 1. 约定
- Base URL:`/api`
- 所有时间戳:毫秒 UTC
- 所有 JSON:`application/json`
- 鉴权:Cookie Session 或 `Authorization: Bearer <token>`(按项目现状)
---
## 2. 配置下发
### `GET /api/realtime/config`
用途:前端启动时拉取实时参数、限制和开关。
响应 `200`:
```json
{
"session": {
"idleTimeoutMs": 120000
},
"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"
},
"limits": {
"steps": { "min": 1, "max": 6 },
"feedback": { "min": 0.7, "max": 1.0 }
}
},
"features": {
"history": true,
"abCompare": true
}
}
```
错误:
- `401` 未登录
- `500` 服务异常
---
## 3. 指标上报
### `POST /api/realtime/metrics`
用途:收集端到端延迟、掉帧、重连、错误等指标。
请求:
```json
{
"sessionId": "rt_abc123",
"events": [
{
"name": "frame_result",
"ts": 1760000000000,
"seq": 102,
"latencyMs": 734,
"queueDepth": 1,
"networkType": "4g",
"deviceClass": "desktop"
}
]
}
```
响应:
```json
{
"ok": true
}
```
错误:
- `400` 参数不合法
- `413` 批量过大
- `429` 超频
---
## 4. fal 代理
### `GET/POST /api/fal/proxy/*`
用途:代理 fal API,隐藏 `FAL_KEY`。
说明:
- 前端 SDK 统一设置:`fal.config({ proxyUrl: "/api/fal/proxy" })`
- 该路由由 `@fal-ai/server-proxy` 托管,不建议自行改协议
后端必须加:
- 来源校验(Origin/Referer)
- 用户/IP 限流
- 请求日志(脱敏)
---
## 5. 前端状态码映射(用户提示)
- `401/403`:登录过期或权限不足 -> 提示重新登录
- `429`:操作过快 -> 自动降频并提示
- `5xx`:服务临时不可用 -> 自动重试 + 提示稍后再试
---
## 6. 帧序号策略(重要)
- FE 本地自增 `seq`
- 发送时记录:`lastSentSeq`
- 回包渲染时检查:`seq >= lastRenderedSeq` 才允许落屏
- 否则丢弃旧帧
---
## 7. 限流建议
- 用户维度:60 req/min
- IP 维度:120 req/min
- 指标上报接口:300 events/min
触发限流响应:
```json
{
"code": "RATE_LIMITED",
"message": "Too many requests",
"retryAfterMs": 1200
}
```
---
## 8. 错误对象约定
```json
{
"code": "INVALID_PARAM",
"message": "steps out of range",
"requestId": "req_xxx"
}
```
字段:
- `code`:机器可读错误码
- `message`:用户可读信息
- `requestId`:排障追踪 ID
---
## 9. 联调清单
- [ ] FE 能拿到 `/api/realtime/config`
- [ ] FE 通过 `/api/fal/proxy` 成功连接并回图
- [ ] 429 时 FE 自动调高节流
- [ ] 断线后自动重连并恢复
- [ ] 指标能在监控系统查询

View File

@ -0,0 +1,449 @@
# fal.ai 实时生图开发文档(接近官方交互体验版)
> 版本:v2.0
> 更新日期:2026-02-19
> 范围:实时图片生成/编辑(不含视频)
> 目标模型:`fal-ai/flux-2/klein/realtime`
---
## 1. 文档目标
这份文档用于让前后端团队直接开工,重点不是“能跑起来”,而是“交互体验接近官方 Realtime”。
### 1.1 成功标准(必须量化)
- 首帧可见延迟(TFF)P50 <= 800ms,P95 <= 1500ms
- 连续交互刷新频率达到 6~12 FPS(取决于设备与网络)
- 拖拽/涂抹过程中 UI 不阻塞主线程(卡顿帧 < 5%)
- 断线重连成功率 > 99%
- 前端不泄露长期密钥 `FAL_KEY`
### 1.2 非目标
- 视频实时生成
- 复杂多模型路由
- 企业级权限系统(本期只做基础鉴权+限流)
---
## 2. 体验对标:你要做成什么感觉
接近官方实时感的核心不是“单次图好看”,而是“连续反馈稳定”。
### 2.1 用户可感知的关键点
1. 用户一动笔,画面很快有变化(低延迟)
2. 连续几帧风格连贯,不要每帧“跳风格”(时序一致性)
3. 结果总是追着“最新输入”走,不被旧请求拖住(最新优先)
4. 网络抖动时不会完全卡死(降级与重连)
### 2.2 交互策略总原则
- 本地先反馈,远端再纠正:先显示本地编辑痕迹,再被 AI 结果覆盖
- 只保留最新帧:旧帧到达就丢弃
- 高频输入要合并:不能每个鼠标事件都发请求
---
## 3. 系统架构(前后端职责)
```mermaid
flowchart LR
U[User] --> FE[Frontend Studio]
FE --> ENC[Encode Worker]
ENC --> RTC[Realtime Client]
RTC --> PX[/api/fal/proxy]
PX --> FAL[fal Realtime Model]
FAL --> RTC
RTC --> REN[Preview Renderer]
REN --> FE
FE --> OBS[Metrics]
PX --> OBS
```
### 3.1 前端职责
- 画布交互、参数面板、提示词输入
- 输入合并(节流、去抖、最新优先)
- 连接管理(连接、重连、失败提示)
- 预览渲染(只显示最新可用帧)
### 3.2 后端职责
- 代理 fal API(隐藏密钥)
- 对用户请求做速率限制、来源校验、审计日志
- 下发配置(参数上限、限流阈值、特性开关)
- 统一监控和告警
---
## 4. 前端功能清单与交互细节(重点)
## 4.1 页面模块
- `CanvasStage`:主画布,支持画笔、橡皮、遮罩、平移缩放
- `PromptBar`:Prompt 输入、负向词、随机种子开关
- `ControlPanel`:步数、强度、尺寸、风格预设
- `HistoryStrip`:结果快照列表(最多保留 N 张)
- `ConnectionBadge`:连接状态(连接中/实时中/重连中/失败)
## 4.2 用户交互流程(逐帧)
1. `pointerdown`:记录起点,进入本地绘制模式
2. `pointermove`:本地画布实时渲染笔触
3. 调度器每 80~120ms 取一次画布快照(不是每次 move 都取)
4. 快照进入编码 Worker 转 Data URI(JPEG)
5. 编码完成后推送到 `connection.send`
6. `onResult` 返回图像帧,渲染层替换预览
7. `pointerup` 立即触发一次 `flush`(强制送最终状态)
## 4.3 关键交互规则(必须实现)
- Prompt 输入时:
- 正在输入不触发(debounce 300~500ms)
- Enter 或输入停止后立即送一帧
- 滑杆参数变更时:
- 拖动过程中 120ms 节流
- 松手后强制再发一帧
- 画布连续操作时:
- 永远“latest wins”,旧序号响应不落屏
## 4.4 本地与远端双层渲染
- 层 1(本地编辑层):即时反馈笔触,0 延迟
- 层 2(AI 结果层):服务端回图,覆盖显示
- 层 3(UI Overlay):网格、框选、蒙版高亮
这样用户不会感觉“操作没反应”。
---
## 5. 前端实现设计(工程级)
## 5.1 状态管理建议(Zustand/Redux 均可)
```ts
interface RealtimeState {
sessionId: string;
status: "idle" | "connecting" | "live" | "retrying" | "error";
prompt: string;
seed: number | null;
params: {
steps: number;
scheduleMu: number;
feedback: number;
imageSize: "square" | "square_hd";
};
seq: number;
lastSentSeq: number;
lastRenderedSeq: number;
queueDepth: number;
}
```
## 5.2 调度器(发送侧)
- 输入事件写入 `dirtyFlag`
- `requestAnimationFrame` 循环里检查:
- 距离上次发送 >= `throttleInterval`
- 当前无编码任务或队列未超阈值
- 满足条件才触发截图+编码+发送
## 5.3 编码 Worker(必须)
主线程不要做重编码,避免掉帧。
职责:
- 接收 `ImageBitmap` 或 `OffscreenCanvas`
- 压缩为 JPEG(质量 0.45~0.65)
- 返回 Data URI(或 bytes + MIME)
## 5.4 结果渲染策略
- 每条发送都附带 `seq`
- `onResult` 解析到响应时,如 `seq < lastRenderedSeq` 直接丢弃
- 只渲染最新帧
## 5.5 连接管理
- 使用 `connectionKey` 保证组件重渲染不重建连接
- 自动重连:500ms -> 1s -> 2s -> 4s -> 8s(上限 10s)
- 重连成功立即发送“最新完整画布+参数”
---
## 6. 前端代码骨架(可直接改造成项目代码)
```ts
import { fal } from "@fal-ai/client";
fal.config({ proxyUrl: "/api/fal/proxy" });
const realtime = fal.realtime.connect("fal-ai/flux-2/klein/realtime", {
connectionKey: "studio-main",
throttleInterval: 100,
maxBuffering: 2,
onResult: (result) => {
const image = result.images?.[result.images.length - 1];
if (!image?.content) return;
const seq = extractSeq(result) ?? 0;
if (seq < store.getState().lastRenderedSeq) return;
store.setState({ lastRenderedSeq: seq });
renderAIFrame(image.content, image.content_type || "image/jpeg");
},
onError: (err) => {
markConnectionError(err);
},
});
async function sendLatestFrame(payload: {
imageDataUri: string;
prompt: string;
seed?: number;
seq: number;
}) {
realtime.send({
image_url: payload.imageDataUri,
prompt: payload.prompt,
image_size: "square",
num_inference_steps: 3,
schedule_mu: 2.3,
seed: payload.seed,
output_feedback_strength: 0.95,
// 如果业务侧支持 metadata,可带 seq 用于对齐
// metadata: { seq: payload.seq }
});
}
```
---
## 7. 后端实现(详细)
## 7.1 最小后端模块
- `fal proxy route`:官方代理
- `auth middleware`:登录态检查(可选)
- `rate limiter`:按 userId/ip 双维限速
- `config endpoint`:给前端下发可调参数
- `metrics ingest`:收集前端埋点
## 7.2 目录建议
```txt
src/
app/
api/
fal/
proxy/
route.ts
realtime/
config/route.ts
metrics/route.ts
server/
rate-limit.ts
auth.ts
observability.ts
```
## 7.3 Proxy 路由(必须)
```ts
// src/app/api/fal/proxy/route.ts
import { route } from "@fal-ai/server-proxy/nextjs";
export const { GET, POST } = route;
```
```bash
# .env
FAL_KEY="key_id:key_secret"
```
## 7.4 参数下发接口(建议)
`GET /api/realtime/config`
返回示例:
```json
{
"throttleInterval": 100,
"maxBuffering": 2,
"jpegQuality": 0.55,
"maxInputResolution": 1024,
"defaults": {
"steps": 3,
"scheduleMu": 2.3,
"feedback": 0.95,
"imageSize": "square"
},
"limits": {
"steps": { "min": 1, "max": 6 },
"feedback": { "min": 0.7, "max": 1.0 }
}
}
```
## 7.5 埋点上报接口(建议)
`POST /api/realtime/metrics`
请求示例:
```json
{
"sessionId": "rt_abc123",
"event": "frame_result",
"ts": 1760000000000,
"latencyMs": 734,
"queueDepth": 1,
"network": "4g"
}
```
## 7.6 限流策略(建议起步)
- 每用户:60 请求/分钟(发送帧)
- 每 IP:120 请求/分钟
- 触发后返回 429 + `retryAfter`
- 前端收到 429:自动把节流增大到 150~200ms
---
## 8. 前后端协议与时序(必须统一)
## 8.1 时序图
```mermaid
sequenceDiagram
participant U as User
participant FE as Frontend
participant W as Encode Worker
participant PX as Backend Proxy
participant F as fal Realtime
U->>FE: 画布操作/改Prompt
FE->>W: snapshot + encode
W-->>FE: dataURI
FE->>PX: realtime send
PX->>F: forward
F-->>PX: result frame
PX-->>FE: result frame
FE-->>U: render latest frame
```
## 8.2 序号对齐(强烈建议)
- FE 发送每帧时维护本地 `seq`
- FE 端维护 `lastSentSeq` / `lastRenderedSeq`
- 到达乱序时只保留最新
---
## 9. 参数策略(默认值 + 调优步骤)
## 9.1 默认值
- `num_inference_steps=3`
- `schedule_mu=2.3`
- `output_feedback_strength=0.95`
- `image_size=square`
- `seed`:默认固定(用户可切换随机)
## 9.2 调优顺序
1. 先把延迟打下来:分辨率/JPEG 质量/节流
2. 再调连续性:固定 seed + feedback
3. 最后调画质:必要时增加 steps(慎重)
---
## 10. 性能预算(必须监控)
把每帧流程拆成预算:
- 画布快照:<= 8ms
- 编码:<= 20ms
- 网络+推理+回传:<= 900ms(P50)
- 解码+渲染:<= 16ms
超过预算时降级:
- 提高 `throttleInterval`
- 降输入分辨率
- 降 JPEG 质量
- 暂停插值/高级效果
---
## 11. 体验增强(接近官方手感的关键)
## 11.1 操作手感
- 光标附近显示笔刷圈和强度
- `space + drag` 平移,`wheel` 缩放
- 参数改动后有轻微过渡动画(100~180ms)
## 11.2 生成手感
- 显示“正在生成”但不阻塞输入
- 回图时使用淡入(80~120ms)避免闪屏
- 支持 A/B 快照对比(按住键切换前后)
## 11.3 可恢复性
- 自动保存最近 N 次快照到 IndexedDB
- 页面刷新后可恢复最近工作态
---
## 12. 错误与降级策略
## 12.1 用户可见文案规范
- 网络波动:`网络不稳定,正在自动重连...`
- 触发限流:`操作过于频繁,已自动降低刷新频率`
- 服务异常:`生成服务暂时不可用,请稍后重试`
## 12.2 技术处理
- WS 断开:自动重连 + 状态提示
- 429:提升节流、短暂停发
- 5xx:指数退避重试,最大 5 次
- 超时:超过阈值主动丢弃旧请求
---
## 13. 测试计划(按真实体验验收)
## 13.1 交互测试
- 连续涂抹 3 分钟,观察是否稳定刷新
- 快速修改 prompt,结果是否快速“跟手”
- 缩放+平移+绘制组合操作是否卡顿
## 13.2 弱网测试
- 模拟高延迟(200ms+)与抖动
- 验证重连和自动降频是否生效
## 13.3 安全测试
- 全站抓包确认无 `FAL_KEY`
- 代理接口防滥用(限流、来源)
---
## 14. 上线清单
- [ ] Proxy 可用且仅服务端持有长期密钥
- [ ] 前端已启用节流、丢旧帧、重连
- [ ] 监控看板有 TFF/P95/错误率/重连次数
- [ ] 429 与 5xx 有可验证的降级路径
- [ ] 移动端和桌面端都可用
---
## 15. 两周落地计划
- D1-D2:Proxy + Realtime 基础链路 + 画布最小版
- D3-D4:调度器、编码 Worker、序号对齐
- D5-D6:参数面板、Prompt 交互、快照历史
- D7-D8:错误与重连、429 降级、状态提示
- D9-D10:埋点接入、性能优化、弱网压测
- D11-D12:UI 手感优化(淡入、过渡、快捷键)
- D13-D14:灰度发布与回归
---
## 16. 官方参考链接
- [Real-time Quickstart](https://docs.fal.ai/model-apis/real-time/quickstart/)
- [Flux 2 klein realtime API](https://fal.ai/models/fal-ai/flux-2/klein/realtime/api)
- [Keeping fal API Secrets Safe](https://docs.fal.ai/model-apis/real-time/secrets/)
- [JavaScript Realtime Client](https://docs.fal.ai/reference/client-libraries/javascript/realtime)
- [Next.js Integration](https://docs.fal.ai/model-apis/integrations/nextjs)
- [Error Reference](https://docs.fal.ai/model-apis/errors/)
---
## 17. 给开发团队的实施备注
- 先“保证反馈连续”再“追求最高画质”
- 任何时候都优先响应最新输入
- 把输入合并、编码异步、旧帧丢弃做到位,体验会非常接近官方
- 如果后续要加视频,可复用当前状态机与连接管理,不要推倒重来

File diff suppressed because it is too large Load Diff