chore: sync all local updates
This commit is contained in:
parent
4993354969
commit
9d7ac81dd1
@ -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,
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
historyRef.current.push(imageData);
|
||||
historyIndexRef.current = historyRef.current.length - 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);
|
||||
}
|
||||
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,110 +1036,205 @@ 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">
|
||||
<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" />
|
||||
{/* 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={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="画笔"
|
||||
>
|
||||
<Paintbrush 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 === "eraser" && "bg-black text-white hover:bg-black"
|
||||
)}
|
||||
onClick={() => setBrushMode("eraser")}
|
||||
title="橡皮"
|
||||
>
|
||||
<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
|
||||
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="mx-1 h-7 w-px bg-black/10" />
|
||||
|
||||
<div className="h-[1px] bg-border/50 mx-2 my-1" />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={clearSketch}
|
||||
title="Clear"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</Button>
|
||||
<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
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleSketchUpload}
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Floating Toolbar - Bottom (Prompt) */}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
{ASPECT_RATIO_OPTIONS.map((option) => (
|
||||
<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"
|
||||
onClick={() => updateParam("aspectRatio", option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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
|
||||
key={option.value}
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<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
|
||||
key={option.value}
|
||||
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>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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)}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
@ -264,10 +370,10 @@ export function ImageParamsBar({
|
||||
{(showPromptExtend || showNegativePrompt) && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 px-2 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0",
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"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="高级设置"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
176
实时画布新功能文档/fal-realtime-image-api-contract.md
Normal file
176
实时画布新功能文档/fal-realtime-image-api-contract.md
Normal 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 自动调高节流
|
||||
- [ ] 断线后自动重连并恢复
|
||||
- [ ] 指标能在监控系统查询
|
||||
|
||||
449
实时画布新功能文档/fal-realtime-image-dev-guide.md
Normal file
449
实时画布新功能文档/fal-realtime-image-dev-guide.md
Normal 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. 给开发团队的实施备注
|
||||
|
||||
- 先“保证反馈连续”再“追求最高画质”
|
||||
- 任何时候都优先响应最新输入
|
||||
- 把输入合并、编码异步、旧帧丢弃做到位,体验会非常接近官方
|
||||
- 如果后续要加视频,可复用当前状态机与连接管理,不要推倒重来
|
||||
|
||||
2990
详情升级文案方案.html
Normal file
2990
详情升级文案方案.html
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user