feat: add realtime canvas feature with admin db config
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-22 02:57:32 +08:00
parent 3acfa3b7a3
commit 18149083d4
10 changed files with 1425 additions and 0 deletions

View File

@ -55,6 +55,7 @@ import {
Video,
Loader2,
Check,
Paintbrush,
} from "lucide-react";
import {
Dialog,
@ -197,6 +198,7 @@ type SiteSettings = {
tutorialEnabled: boolean;
rankingEnabled: boolean;
canvasEnabled: boolean;
realtimeCanvasEnabled: boolean;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
@ -519,6 +521,7 @@ export function AdminSettingsClient() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
realtimeCanvasEnabled: true,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
@ -587,6 +590,7 @@ export function AdminSettingsClient() {
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
@ -695,6 +699,20 @@ export function AdminSettingsClient() {
timeout: 120,
});
// 实时画布API配置状态
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
enabled: true,
falApiKey: "",
throttleInterval: 100,
maxBuffering: 2,
maxInputResolution: 1024,
jpegQuality: 0.55,
steps: 3,
scheduleMu: 2.3,
feedback: 0.95,
imageSize: "square" as "square" | "square_hd",
});
// 详情替换配置状态
const [sceneFusionConfig, setSceneFusionConfig] = useState({
enabled: true,
@ -821,6 +839,42 @@ export function AdminSettingsClient() {
}
}, [angleEditConfigData]);
// 加载实时画布API配置
const { data: realtimeCanvasConfigData } = useSWR(
"/api/admin/settings?key=realtime_canvas_config",
fetcher
);
useEffect(() => {
if (realtimeCanvasConfigData?.setting?.value) {
const config = realtimeCanvasConfigData.setting.value as {
enabled?: boolean;
falApiKey?: string;
throttleInterval?: number;
maxBuffering?: number;
maxInputResolution?: number;
jpegQuality?: number;
defaults?: {
steps?: number;
scheduleMu?: number;
feedback?: number;
imageSize?: "square" | "square_hd";
};
};
setRealtimeCanvasConfig({
enabled: config.enabled ?? true,
falApiKey: config.falApiKey || "",
throttleInterval: Number(config.throttleInterval || 100),
maxBuffering: Number(config.maxBuffering || 2),
maxInputResolution: Number(config.maxInputResolution || 1024),
jpegQuality: Number(config.jpegQuality || 0.55),
steps: Number(config.defaults?.steps || 3),
scheduleMu: Number(config.defaults?.scheduleMu || 2.3),
feedback: Number(config.defaults?.feedback || 0.95),
imageSize: config.defaults?.imageSize === "square_hd" ? "square_hd" : "square",
});
}
}, [realtimeCanvasConfigData]);
// 加载详情替换配置
const { data: sceneFusionConfigData } = useSWR("/api/admin/settings?key=scene_fusion_config", fetcher);
useEffect(() => {
@ -1635,6 +1689,46 @@ export function AdminSettingsClient() {
}
};
// --- 实时画布API配置逻辑 ---
const handleSaveRealtimeCanvasConfig = async () => {
try {
if (realtimeCanvasConfig.enabled && !realtimeCanvasConfig.falApiKey.trim()) {
toast.error("启用实时画布功能需要配置 fal.ai API Key");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "realtime_canvas_config",
value: {
enabled: realtimeCanvasConfig.enabled,
falApiKey: realtimeCanvasConfig.falApiKey.trim(),
throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)),
maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)),
maxInputResolution: Math.min(
1536,
Math.max(512, Number(realtimeCanvasConfig.maxInputResolution) || 1024)
),
jpegQuality: Math.min(0.82, Math.max(0.35, Number(realtimeCanvasConfig.jpegQuality) || 0.55)),
defaults: {
steps: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.steps) || 3)),
scheduleMu: Math.min(3.5, Math.max(1.5, Number(realtimeCanvasConfig.scheduleMu) || 2.3)),
feedback: Math.min(1, Math.max(0.7, Number(realtimeCanvasConfig.feedback) || 0.95)),
imageSize: realtimeCanvasConfig.imageSize,
},
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("实时画布配置保存成功");
} catch (_error) {
toast.error("保存实时画布配置失败");
}
};
// --- 详情替换配置逻辑 ---
const handleSaveSceneFusionConfig = async () => {
try {
@ -4554,6 +4648,22 @@ export function AdminSettingsClient() {
</div>
{siteSettings.canvasEnabled && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="realtimeCanvasEnabled">实时画布功能</Label>
<div className="text-xs text-muted-foreground">
开启后,无限画布左侧工具栏显示“实时画布”按钮;关闭后前端隐藏。
</div>
</div>
<Switch
id="realtimeCanvasEnabled"
checked={siteSettings.realtimeCanvasEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, realtimeCanvasEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="aiDetailGenerationEnabled">AI详情生成功能</Label>
@ -7092,6 +7202,192 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
{/* 实时画布API配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Paintbrush className="h-5 w-5" />
实时画布配置
</CardTitle>
<CardDescription>
配置实时画布(左画右预览)功能使用的 fal realtime 参数与 API Key。该 Key 存储在数据库,前端不暴露。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用实时画布服务</Label>
<div className="text-sm text-muted-foreground">
关闭后前端“实时画布”入口仍可显示,但会提示服务未启用。
</div>
</div>
<Switch
checked={realtimeCanvasConfig.enabled}
onCheckedChange={(checked) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, enabled: checked })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="realtimeFalApiKey">
fal.ai API Key <span className="text-destructive">*</span>
</Label>
<Input
id="realtimeFalApiKey"
type="password"
value={realtimeCanvasConfig.falApiKey}
onChange={(e) =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, falApiKey: e.target.value })
}
placeholder="fal_xxxxxxxxxxxxx"
disabled={!realtimeCanvasConfig.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>发送节流(ms)</Label>
<Input
type="number"
min={60}
max={260}
value={realtimeCanvasConfig.throttleInterval}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
throttleInterval: Number.parseInt(e.target.value, 10) || 100,
})
}
/>
</div>
<div className="space-y-2">
<Label>最大缓冲帧</Label>
<Input
type="number"
min={1}
max={6}
value={realtimeCanvasConfig.maxBuffering}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
maxBuffering: Number.parseInt(e.target.value, 10) || 2,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>输入分辨率上限(px)</Label>
<Input
type="number"
min={512}
max={1536}
value={realtimeCanvasConfig.maxInputResolution}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
maxInputResolution: Number.parseInt(e.target.value, 10) || 1024,
})
}
/>
</div>
<div className="space-y-2">
<Label>JPEG质量</Label>
<Input
type="number"
min={0.35}
max={0.82}
step={0.01}
value={realtimeCanvasConfig.jpegQuality}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
jpegQuality: Number.parseFloat(e.target.value) || 0.55,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>推理步数</Label>
<Input
type="number"
min={1}
max={6}
value={realtimeCanvasConfig.steps}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
steps: Number.parseInt(e.target.value, 10) || 3,
})
}
/>
</div>
<div className="space-y-2">
<Label>反馈强度</Label>
<Input
type="number"
min={0.7}
max={1}
step={0.01}
value={realtimeCanvasConfig.feedback}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
feedback: Number.parseFloat(e.target.value) || 0.95,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>schedule_mu</Label>
<Input
type="number"
min={1.5}
max={3.5}
step={0.1}
value={realtimeCanvasConfig.scheduleMu}
onChange={(e) =>
setRealtimeCanvasConfig({
...realtimeCanvasConfig,
scheduleMu: Number.parseFloat(e.target.value) || 2.3,
})
}
/>
</div>
<div className="space-y-2">
<Label>输出尺寸</Label>
<Select
value={realtimeCanvasConfig.imageSize}
onValueChange={(value: "square" | "square_hd") =>
setRealtimeCanvasConfig({ ...realtimeCanvasConfig, imageSize: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="square">square</SelectItem>
<SelectItem value="square_hd">square_hd</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button onClick={handleSaveRealtimeCanvasConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 详情替换配置 */}
<Card>
<CardHeader>

116
app/api/fal/proxy/route.ts Normal file
View File

@ -0,0 +1,116 @@
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
const ALLOWED_FAL_HOSTS = new Set(["rest.alpha.fal.ai", "rest.fal.ai"]);
type FalRealtimeConfig = {
enabled?: boolean;
falApiKey?: string;
};
async function getRealtimeCanvasConfig() {
const realtimeSetting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(
() => null
);
return (realtimeSetting?.value || {}) as FalRealtimeConfig;
}
async function handleProxy(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
}
const targetUrl = request.headers.get("x-fal-target-url");
if (!targetUrl) {
return Response.json(
{ code: "INVALID_TARGET", message: "缺少 x-fal-target-url 请求头" },
{ status: 400 }
);
}
let parsedUrl: URL;
try {
parsedUrl = new URL(targetUrl);
} catch {
return Response.json(
{ code: "INVALID_TARGET", message: "x-fal-target-url 不是合法 URL" },
{ status: 400 }
);
}
if (!ALLOWED_FAL_HOSTS.has(parsedUrl.host)) {
return Response.json(
{ code: "TARGET_NOT_ALLOWED", message: "不允许代理该目标地址" },
{ status: 403 }
);
}
const realtimeConfig = await getRealtimeCanvasConfig();
if (realtimeConfig.enabled === false) {
return Response.json(
{
code: "FEATURE_DISABLED",
message: "实时画布服务未启用(请在本地AI设置中开启)",
},
{ status: 403 }
);
}
const falApiKey = realtimeConfig.falApiKey?.trim() || "";
if (!falApiKey) {
return Response.json(
{
code: "FAL_KEY_MISSING",
message: "未配置 FAL API Key(请在本地AI设置的实时画布配置中填写)",
},
{ status: 500 }
);
}
let body: ArrayBuffer | undefined;
if (request.method !== "GET" && request.method !== "HEAD") {
body = await request.arrayBuffer();
}
const headers = new Headers();
headers.set("Authorization", `Key ${falApiKey}`);
headers.set("Accept", request.headers.get("accept") || "application/json");
const contentType = request.headers.get("content-type");
if (contentType) {
headers.set("Content-Type", contentType);
}
const upstreamResponse = await fetch(parsedUrl.toString(), {
method: request.method,
headers,
body,
cache: "no-store",
});
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: upstreamResponse.headers,
});
}
export async function GET(request: Request) {
return handleProxy(request);
}
export async function POST(request: Request) {
return handleProxy(request);
}
export async function PUT(request: Request) {
return handleProxy(request);
}
export async function PATCH(request: Request) {
return handleProxy(request);
}
export async function DELETE(request: Request) {
return handleProxy(request);
}

View File

@ -0,0 +1,133 @@
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = {
enabled?: boolean;
falApiKey?: string;
throttleInterval?: number;
maxBuffering?: number;
maxInputResolution?: number;
jpegQuality?: number;
defaults?: {
steps?: number;
scheduleMu?: number;
feedback?: number;
imageSize?: "square" | "square_hd";
};
};
const defaultConfig = {
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" as const,
},
limits: {
steps: { min: 1, max: 6 },
feedback: { min: 0.7, max: 1.0 },
},
},
features: {
history: true,
abCompare: false,
},
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
}
const setting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null);
const config = (setting?.value || {}) as RealtimeCanvasConfig;
if (config.enabled === false) {
return Response.json(
{ code: "FEATURE_DISABLED", message: "实时画布功能未开启" },
{ status: 403 }
);
}
if (!String(config.falApiKey || "").trim()) {
return Response.json(
{
code: "FAL_KEY_MISSING",
message: "未配置实时画布 API Key(请在本地AI设置中配置)",
},
{ status: 500 }
);
}
const throttleInterval = clamp(
Number(config.throttleInterval || defaultConfig.transport.throttleInterval),
60,
260
);
const maxBuffering = clamp(
Number(config.maxBuffering || defaultConfig.transport.maxBuffering),
1,
6
);
const maxInputResolution = clamp(
Number(config.maxInputResolution || defaultConfig.image.maxInputResolution),
512,
1536
);
const jpegQuality = clamp(
Number(config.jpegQuality || defaultConfig.image.jpegQuality),
0.35,
0.82
);
const steps = clamp(Number(config.defaults?.steps || defaultConfig.model.defaults.steps), 1, 6);
const scheduleMu = clamp(
Number(config.defaults?.scheduleMu || defaultConfig.model.defaults.scheduleMu),
1.5,
3.5
);
const feedback = clamp(
Number(config.defaults?.feedback || defaultConfig.model.defaults.feedback),
0.7,
1.0
);
const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
return Response.json({
...defaultConfig,
transport: {
throttleInterval,
maxBuffering,
},
image: {
maxInputResolution,
jpegQuality,
},
model: {
...defaultConfig.model,
defaults: {
steps,
scheduleMu,
feedback,
imageSize,
},
},
});
}

View File

@ -0,0 +1,54 @@
import { auth } from "@/app/(auth)/auth";
type MetricEvent = {
name: string;
ts: number;
seq?: number;
latencyMs?: number;
queueDepth?: number;
networkType?: string;
deviceClass?: string;
};
type MetricsPayload = {
sessionId: string;
events: MetricEvent[];
};
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
}
let payload: MetricsPayload;
try {
payload = (await request.json()) as MetricsPayload;
} catch {
return Response.json({ code: "BAD_REQUEST", message: "无效 JSON" }, { status: 400 });
}
if (!payload?.sessionId || !Array.isArray(payload.events)) {
return Response.json({ code: "BAD_REQUEST", message: "缺少 sessionId 或 events" }, { status: 400 });
}
if (payload.events.length > 200) {
return Response.json(
{ code: "PAYLOAD_TOO_LARGE", message: "events 超过限制(200)" },
{ status: 413 }
);
}
if (payload.events.length > 0) {
const latest = payload.events[payload.events.length - 1];
console.log("[RealtimeMetrics]", {
userId: session.user.id,
sessionId: payload.sessionId,
count: payload.events.length,
latest,
});
}
return Response.json({ ok: true });
}

View File

@ -283,6 +283,7 @@ export async function GET() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
realtimeCanvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
canvasDetailDefaultModelId: null,
@ -364,6 +365,7 @@ const updateSchema = z.object({
tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
@ -754,6 +756,7 @@ export async function PUT(request: NextRequest) {
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
canvasDetailDefaultModelId: siteSettingsPayload.canvasDetailDefaultModelId,

View File

@ -23,6 +23,7 @@ import {
Wand2,
Grid3X3,
Settings,
PenTool,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -58,6 +59,7 @@ import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
import { RealtimeCanvasDialog } from "@/components/canvas/RealtimeCanvasDialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn, fetcher } from "@/lib/utils";
import { MessageSquare, FolderOpen, Grid2X2 } from "lucide-react";
@ -121,11 +123,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [isPanelOpen, setIsPanelOpen] = useState(true);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
const [isRealtimeCanvasOpen, setIsRealtimeCanvasOpen] = useState(false);
const { data: siteSettingsData } = useSWR<{
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
realtimeCanvasEnabled?: boolean;
}>("/api/site-settings", fetcher);
// 生成配置
@ -353,6 +357,38 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
};
const handleApplyRealtimeResult = useCallback(
({ imageUrl, prompt: imagePrompt }: { imageUrl: string; prompt: string }) => {
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
const targetWidth = 360;
const targetHeight = 360;
const x = (stageWidth * 0.45 - viewportX) / viewportZoom - targetWidth / 2;
const y = (stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
addNode({
type: "image",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: imageUrl,
status: "completed",
title: "实时画布结果",
prompt: imagePrompt,
model: "fal-ai/flux-2/klein/realtime",
},
connections: [],
});
toast.success("实时画布结果已添加");
},
[addNode, viewport.x, viewport.y, viewport.zoom]
);
// 处理图片操作(通过事件分发到选中图片节点执行)
const handleAction = (action: string) => {
if (!selectedImageNode) {
@ -717,6 +753,22 @@ export default function CanvasEditorPage({ params }: PageProps) {
<TooltipContent side="right">添加图片</TooltipContent>
</Tooltip>
{(siteSettingsData?.realtimeCanvasEnabled ?? true) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsRealtimeCanvasOpen(true)}
className="rounded-xl h-9 w-9"
>
<PenTool className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">实时画布</TooltipContent>
</Tooltip>
) : null}
<Separator orientation="horizontal" className="w-5 my-0.5" />
<Tooltip>
@ -756,6 +808,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
{/* 6. 物品拾取模式覆盖层 */}
<ObjectPickerOverlay />
<RealtimeCanvasDialog
open={isRealtimeCanvasOpen}
onOpenChange={setIsRealtimeCanvasOpen}
initialImageUrl={selectedImageNode ? ((selectedImageNode.data as any)?.src as string) : null}
onApplyResult={handleApplyRealtimeResult}
/>
</div>
</TooltipProvider>
);

View File

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

View File

@ -0,0 +1,2 @@
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "realtimeCanvasEnabled" boolean NOT NULL DEFAULT true;

View File

@ -197,6 +197,13 @@
"when": 1772265600000,
"tag": "0041_add_user_high_vip",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1772280000000,
"tag": "0042_add_realtime_canvas_toggle",
"breakpoints": true
}
]
}

View File

@ -354,6 +354,7 @@ export const siteSettings = pgTable("SiteSettings", {
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
// 无限画布设置
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
realtimeCanvasEnabled: boolean("realtimeCanvasEnabled").notNull().default(true), // 是否显示实时画布入口
canvasDefaultModelId: varchar("canvasDefaultModelId", { length: 128 }), // 画布默认选择的模型ID(null表示智能自动)
canvasAutoModeDefaultModelId: varchar("canvasAutoModeDefaultModelId", { length: 128 }), // 智能自动模式下实际使用的默认生图模型ID
canvasDetailDefaultModelId: varchar("canvasDetailDefaultModelId", { length: 128 }), // AI详情生成专用默认生图模型ID