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"); ].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[] { function extractMessageImageUrls(parts: any[]): string[] {
return (parts || []) return (parts || [])
.map((part: any) => { .map((part: any) => {
@ -1766,6 +1805,7 @@ export async function POST(request: Request) {
let smartAutoHistoryImages: string[] = []; // 从历史对话中提取的图片URL(支持多张) let smartAutoHistoryImages: string[] = []; // 从历史对话中提取的图片URL(支持多张)
let smartAutoNeedsClarification = false; // 是否需要先追问用户 let smartAutoNeedsClarification = false; // 是否需要先追问用户
let smartAutoClarificationQuestion: string | undefined; let smartAutoClarificationQuestion: string | undefined;
let preferredAgentModelId: string | undefined;
const deepThinkingOn = deepThinkingEnabled !== false; const deepThinkingOn = deepThinkingEnabled !== false;
let shouldRunAgentConversation = finalModelId === "auto"; let shouldRunAgentConversation = finalModelId === "auto";
let shouldKeepSelectedModel = false; let shouldKeepSelectedModel = false;
@ -1829,8 +1869,9 @@ export async function POST(request: Request) {
} else if (isFixedVisualModel) { } else if (isFixedVisualModel) {
shouldRunAgentConversation = true; shouldRunAgentConversation = true;
shouldKeepSelectedModel = true; shouldKeepSelectedModel = true;
preferredAgentModelId = selectedModelConfig.id;
console.log( 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)) { } else if (isFixedVisualModel || isAiModelAgentAuto(selectedModelConfig)) {
@ -2002,6 +2043,7 @@ export async function POST(request: Request) {
imageFromHistory: smartAutoHistoryImages.length > 0, imageFromHistory: smartAutoHistoryImages.length > 0,
historyMessages, historyMessages,
context: autoContextText || undefined, context: autoContextText || undefined,
preferredModelId: preferredAgentModelId,
}), }),
"runAutoAgentGraph", "runAutoAgentGraph",
SMART_AUTO_GRAPH_TIMEOUT_MS, SMART_AUTO_GRAPH_TIMEOUT_MS,
@ -2180,6 +2222,7 @@ export async function POST(request: Request) {
imageFromHistory: autoFromHistoryForFallback || smartAutoHistoryImages.length > 0, imageFromHistory: autoFromHistoryForFallback || smartAutoHistoryImages.length > 0,
historyMessages: [], historyMessages: [],
context: undefined, context: undefined,
preferredModelId: preferredAgentModelId,
}), }),
"fallbackAnalyzeIntent", "fallbackAnalyzeIntent",
SMART_AUTO_FALLBACK_TIMEOUT_MS, SMART_AUTO_FALLBACK_TIMEOUT_MS,
@ -2222,6 +2265,7 @@ export async function POST(request: Request) {
text: autoTextForFallback, text: autoTextForFallback,
imageUrls: summaryImageUrls, imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined, context: typeof autoContext === "string" ? autoContext : undefined,
preferredModelId: preferredAgentModelId,
}), }),
"fallbackVisualSummary", "fallbackVisualSummary",
SMART_AUTO_FALLBACK_TIMEOUT_MS, 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"); 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 hasUserCustomImageParams = hasUserCustomizedImageParams(requestBody.imageParams);
const hasUserCustomVideoParams = hasUserCustomizedVideoParams(requestBody.videoParams); const hasUserCustomVideoParams = hasUserCustomizedVideoParams(requestBody.videoParams);
const shouldApplyAgentImageParams = const shouldApplyAgentImageParams =
@ -2437,6 +2498,7 @@ export async function POST(request: Request) {
text: latestUserText, text: latestUserText,
imageUrls: summaryImageUrls, imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined, context: typeof autoContext === "string" ? autoContext : undefined,
preferredModelId: preferredAgentModelId,
}), }),
"enrichImageAnalysisSummary", "enrichImageAnalysisSummary",
timeoutMs, 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", "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" 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' ? ( {panelMode === 'chat' ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载 // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
<div className="flex-1 overflow-hidden" key={`chat-${id}`}> <div className="flex-1 overflow-hidden" key={`chat-${id}`}>

View File

@ -188,7 +188,7 @@ export function AngleEditDialog({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent <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} onPointerDownCapture={stopDialogEventCapture}
onPointerMoveCapture={stopDialogEventCapture} onPointerMoveCapture={stopDialogEventCapture}
onPointerUpCapture={stopDialogEventCapture} onPointerUpCapture={stopDialogEventCapture}

View File

@ -99,7 +99,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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"> <DialogHeader className="p-4 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle>已生成文件列表</DialogTitle> <DialogTitle>已生成文件列表</DialogTitle>

View File

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

View File

@ -1906,7 +1906,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{/* 文字编辑面板(显示在图片节点旁边) */} {/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal( {isSelected && textEditPanelOpen && data.src && typeof document !== "undefined" && createPortal(
<div <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={{ style={{
top: getTextPanelPosition().top, top: getTextPanelPosition().top,
left: getTextPanelPosition().left, left: getTextPanelPosition().left,

View File

@ -1686,7 +1686,7 @@ export function LovartHome({
</span> </span>
</div> </div>
</SelectTrigger> </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"> <div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{allComposerModels.map((model) => ( {allComposerModels.map((model) => (
<SelectItem <SelectItem
@ -2105,7 +2105,7 @@ export function LovartHome({
) : null} ) : null}
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}> <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"> <DialogHeader className="sr-only">
<DialogTitle>账户管理</DialogTitle> <DialogTitle>账户管理</DialogTitle>
</DialogHeader> </DialogHeader>
@ -2264,7 +2264,7 @@ export function LovartHome({
</Dialog> </Dialog>
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}> <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"> <div className="flex h-full max-h-[90vh] flex-col">
<DialogHeader className="border-b border-black/6 px-5 py-4"> <DialogHeader className="border-b border-black/6 px-5 py-4">
<DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle> <DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle>

View File

@ -8,7 +8,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } 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 { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@ -72,6 +72,12 @@ const IMAGE_SIZE_OPTIONS = [
{ value: "4K", label: "4K", disabled: false }, { value: "4K", label: "4K", disabled: false },
] as const; ] as const;
const IMAGE_SIZE_PIXEL_MAP: Record<string, number> = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
};
export function ImageParamsBar({ export function ImageParamsBar({
modelName = "Gemini 图片生成", modelName = "Gemini 图片生成",
initialParams, initialParams,
@ -145,6 +151,58 @@ export function ImageParamsBar({
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend; const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt; 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 变化,确保状态同步 // 🆕 监听 initialParams 变化,确保状态同步
useEffect(() => { useEffect(() => {
console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams); console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams);
@ -176,71 +234,119 @@ export function ImageParamsBar({
// 内联模式:只返回参数按钮,嵌入工具栏 // 内联模式:只返回参数按钮,嵌入工具栏
if (inline) { 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 ( return (
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1"> <div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
{/* 图片比例 */} <Popover>
<DropdownMenu> <PopoverTrigger asChild>
<DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" 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" 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}`} title={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
> >
{params.aspectRatio === "auto" ? "自动" : params.aspectRatio} <span className="mr-1.5 flex h-4 w-4 items-center justify-center text-[#20262f]">
<span className="sr-only">{`比例:${params.aspectRatio}`}</span> {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> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent align="center" className="w-48"> <PopoverContent
<div className="p-1.5 grid grid-cols-3 gap-1"> align="start"
{ASPECT_RATIO_OPTIONS.map((option) => ( side="top"
<Button sideOffset={10}
key={option.value} 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)]"
variant={params.aspectRatio === option.value ? "secondary" : "ghost"} >
size="sm" <div className="space-y-5">
className="h-7 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0" <div>
onClick={() => updateParam("aspectRatio", option.value)} <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">
{option.label} {ASPECT_RATIO_OPTIONS.map((option) => (
</Button> <button
))} key={option.value}
</div> type="button"
</DropdownMenuContent> className={cn(
</DropdownMenu> "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)}
>
<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>
</div>
{/* 分辨率 */} <div>
<DropdownMenu> <p className="text-[13px] font-medium text-[#8a94a3]">选择分辨率</p>
<DropdownMenuTrigger asChild> <div className="mt-2 grid grid-cols-2 gap-2 rounded-[18px] bg-[#eceff2]/90 p-1.5">
<Button {IMAGE_SIZE_OPTIONS.filter((option) => !option.disabled).map((option) => (
variant="ghost" <button
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" key={option.value}
title={`分辨率:${params.imageSize}`} type="button"
> className={cn(
{params.imageSize} "h-16 rounded-[14px] text-[18px] font-semibold transition md:text-[20px]",
<span className="sr-only">{`分辨率:${params.imageSize}`}</span> params.imageSize === option.value
</Button> ? "bg-white text-[#111827] shadow-[0_2px_8px_rgba(15,23,42,0.08)]"
</DropdownMenuTrigger> : "bg-transparent text-[#5f6b7a] hover:bg-white/70"
<DropdownMenuContent align="center" className="min-w-[100px]"> )}
{IMAGE_SIZE_OPTIONS.map((option) => ( onClick={() => updateParam("imageSize", option.value)}
<DropdownMenuItem >
key={option.value} <span className="inline-flex items-center gap-1">
onClick={() => updateParam("imageSize", option.value)} {option.value === "4K" ? "超清 4K" : `高清 ${option.label}`}
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0" {option.value === "4K" ? <Sparkles className="h-4 w-4 text-[#0ea5e9]" /> : null}
> </span>
<Check className={cn("mr-2 h-4 w-4", params.imageSize === option.value ? "opacity-100" : "opacity-0")} /> </button>
{option.label} ))}
</DropdownMenuItem> </div>
))} </div>
</DropdownMenuContent>
</DropdownMenu> <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 && ( {showImageCountSelector && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" 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}张`} title={`数量:${params.imageCount || 1}张`}
> >
<Sparkles className="mr-1 h-3.5 w-3.5 text-[#6b7280]" />
{params.imageCount || 1}张 {params.imageCount || 1}张
<span className="sr-only">{`数量:${params.imageCount || 1}张`}</span> <span className="sr-only">{`数量:${params.imageCount || 1}张`}</span>
</Button> </Button>
@ -264,10 +370,10 @@ export function ImageParamsBar({
{(showPromptExtend || showNegativePrompt) && ( {(showPromptExtend || showNegativePrompt) && (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className={cn( 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" (params.promptExtend || params.negativePrompt) && "text-primary"
)} )}
title="高级设置" title="高级设置"

View File

@ -1567,7 +1567,7 @@ function PureModelSelectorCompact({
</Button> </Button>
)} )}
</Trigger> </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"> <div className="max-h-[338px] space-y-0.5 overflow-y-auto pr-1">
{displayModels.map((model) => ( {displayModels.map((model) => (
<SelectItem <SelectItem

View File

@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -39,7 +39,7 @@ const DialogContent = forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@ -125,4 +125,3 @@ export {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
}; };

View File

@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -18,7 +18,7 @@ const HoverCardContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}
@ -31,4 +31,3 @@ const PopoverContent = React.forwardRef<
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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" && 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", "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 className

View File

@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( 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: { variants: {
side: { side: {

View File

@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -501,7 +501,7 @@ const getImageUploadConfig = () => {
} }
return ( 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 && ( {imageConfig.max > 0 && (
<div className="pb-2 flex justify-center"> <div className="pb-2 flex justify-center">

View File

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

View File

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

View File

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