chore: sync all local updates
This commit is contained in:
parent
4993354969
commit
9d7ac81dd1
@ -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,
|
||||||
|
|||||||
@ -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}`}>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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="高级设置"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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