fix: async canvas image generation queue and stabilize runtime
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-24 12:00:51 +08:00
parent d8a9e15426
commit 598cdfd736
12 changed files with 793 additions and 257 deletions

View File

@ -2584,7 +2584,7 @@ export async function POST(request: Request) {
// 如果时间检查失败,继续执行(避免因检查逻辑错误导致系统不可用) // 如果时间检查失败,继续执行(避免因检查逻辑错误导致系统不可用)
} }
const chat = await getChatById({ id }); let chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = []; let messagesFromDb: DBMessage[] = [];
if (chat) { if (chat) {
@ -2598,13 +2598,40 @@ export async function POST(request: Request) {
message, message,
}); });
await saveChat({ try {
id, await saveChat({
userId: session.user.id, id,
title, userId: session.user.id,
visibility: selectedVisibilityType, title,
}); visibility: selectedVisibilityType,
// New chat - no need to fetch messages, it's empty });
} catch (error) {
const racedChat = await getChatById({ id });
if (!racedChat) {
throw error;
}
if (racedChat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
console.warn("[Chat API] saveChat conflicted, reused existing chat:", {
chatId: id,
userId: session.user.id,
});
}
chat = await getChatById({ id });
if (!chat) {
throw new ChatSDKError("bad_request:database", "Failed to save chat");
}
if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
// In a race scenario another request may have already written messages.
messagesFromDb = await getMessagesByChatId({ id });
} }
const uiMessages = [...convertToUIMessages(messagesFromDb), message]; const uiMessages = [...convertToUIMessages(messagesFromDb), message];

View File

@ -1,56 +1,87 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getEnabledAiModelsWithProvider, getAiProviderById } from "@/lib/db/queries"; import {
import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; createCanvasImageTask,
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image"; enqueueCanvasImageTask,
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image"; getCanvasImageTaskForUser,
import { createFalImage } from "@/lib/ai/providers/fal-image"; } from "@/lib/canvas/image-task-queue";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image";
export const maxDuration = 120; export const maxDuration = 120;
interface GenerateImageRequest { interface GenerateImageRequest extends CanvasGenerateImageRequest {
prompt: string; async?: boolean;
negativePrompt?: string;
model?: string; // 模型 ID
aspectRatio?: string; // 宽高比 "1:1", "16:9" 等
imageCount?: number; // 生成数量
referenceImage?: string; // 参考图片(用于图生图)
referenceImages?: string[]; // 多参考图(用于多角度/细节一致性)
} }
interface ParsedImage { function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
thumbnailUrl: string; return {
originalUrl: string; prompt: String(body.prompt || "").trim(),
negativePrompt:
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
model: typeof body.model === "string" ? body.model : undefined,
aspectRatio:
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
? body.aspectRatio.trim()
: "1:1",
imageCount:
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
? body.imageCount
: 1,
referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages)
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
: [],
};
} }
function parseImagesFromMarkdown(markdown: string): ParsedImage[] { export async function GET(request: Request) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; try {
const matches = [...markdown.matchAll(imageRegex)]; const session = await auth();
const parsed: ParsedImage[] = []; if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
for (const match of matches) {
const altText = match[1] || "";
const thumbnailUrl = match[2];
let originalUrl = thumbnailUrl;
if (altText.includes("thumb|||")) {
const content = altText.substring(altText.indexOf("thumb|||") + 8);
const parts = content.split("|||");
originalUrl = parts[0] || thumbnailUrl;
} else if (altText.includes("thumb$$")) {
const content = altText.substring(altText.indexOf("thumb$$") + 7);
const parts = content.split("$$");
originalUrl = parts[0] || thumbnailUrl;
} else if (altText.includes("thumb|")) {
const content = altText.substring(altText.indexOf("thumb|") + 6);
const parts = content.split("|");
originalUrl = parts[0] || thumbnailUrl;
} }
parsed.push({ thumbnailUrl, originalUrl }); const { searchParams } = new URL(request.url);
} const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId 参数" },
{ status: 400 }
);
}
return parsed; const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
if (!task) {
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
}
return NextResponse.json({
success: true,
task: {
id: task.id,
status: task.status,
attempts: task.attempts,
error: task.error || null,
result: task.result || null,
createdAt: task.createdAt,
startedAt: task.startedAt,
completedAt: task.completedAt,
},
});
} catch (error) {
console.error("[Canvas Generate Image][TaskStatus] Error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询任务失败",
},
{ status: 500 }
);
}
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@ -64,160 +95,33 @@ export async function POST(request: Request) {
} }
const body: GenerateImageRequest = await request.json(); const body: GenerateImageRequest = await request.json();
const { const payload = toGenerateImagePayload(body);
prompt, if (!payload.prompt) {
negativePrompt,
model,
aspectRatio = "1:1",
imageCount = 1,
referenceImage,
referenceImages,
} = body;
if (!prompt) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: "请输入提示词" }, { success: false, error: "请输入提示词" },
{ status: 400 } { status: 400 }
); );
} }
// 获取可用的图片生成模型(筛选 supportsImageGen 的模型) const asyncMode = body.async === true;
const allModels = await getEnabledAiModelsWithProvider(); if (asyncMode) {
const imageModels = allModels.filter(m => m.model.supportsImageGen); const task = await createCanvasImageTask({
userId: session.user.id,
if (!imageModels || imageModels.length === 0) { request: payload,
return NextResponse.json(
{ success: false, error: "暂无可用的图片生成模型" },
{ status: 400 }
);
}
// 选择模型(如果指定了 model,使用指定的;否则使用默认的)
let selectedModel = model
? imageModels.find(m => m.model.modelId === model || m.model.id === model)
: imageModels.find(m => m.model.isDefault) || imageModels[0];
if (!selectedModel) {
selectedModel = imageModels[0];
}
const provider = await getAiProviderById({ id: selectedModel.model.providerId });
if (!provider) {
return NextResponse.json(
{ success: false, error: "模型提供商配置错误" },
{ status: 500 }
);
}
const normalizedReferenceImages = Array.from(
new Set(
[
...(Array.isArray(referenceImages) ? referenceImages : []),
...(referenceImage ? [referenceImage] : []),
]
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 12);
// 构建消息
const messages = [{
role: "user" as const,
content: normalizedReferenceImages.length > 0
? [
{ type: "text" as const, text: prompt },
...normalizedReferenceImages.map((url) => ({
type: "image_url" as const,
image_url: { url },
})),
]
: prompt,
}];
// 图片参数
const imageParams = {
aspectRatio,
imageSize: "2K",
imageCount,
negativePrompt,
};
let imageMarkdown = "";
// 根据提供商类型调用不同的生成器
if (provider.type === "gemini") {
const geminiProvider = createGeminiImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
}); });
await enqueueCanvasImageTask(task.id);
const model = geminiProvider.chat(selectedModel.model.modelId);
const result = await model.doGenerate({
messages,
imageParams,
});
imageMarkdown = result.text || "";
} else if (provider.type === "alibaba-image") {
const alibabaProvider = createAlibabaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const model = alibabaProvider.chat(selectedModel.model.modelId);
const result = await model.doGenerate({
messages,
imageParams,
});
imageMarkdown = result.text || "";
} else if (provider.type === "ruxa-image") {
const ruxaProvider = createRuxaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const model = ruxaProvider.chat(selectedModel.model.modelId);
const result = await model.doGenerate({
messages,
imageParams,
});
imageMarkdown = result.text || "";
} else if (provider.type === "fal-image") {
const falProvider = createFalImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const model = falProvider.chat(selectedModel.model.modelId);
const result = await model.doGenerate({
messages,
imageParams,
});
imageMarkdown = result.text || "";
} else {
return NextResponse.json(
{ success: false, error: "不支持的图片生成模型类型" },
{ status: 400 }
);
}
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
if (parsedImages.length > 0) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { taskId: task.id,
images: parsedImages, status: task.status,
model: selectedModel.model.displayName,
prompt,
}
}); });
} else {
return NextResponse.json(
{ success: false, error: "图片生成失败" },
{ status: 500 }
);
} }
const result = await generateCanvasImage(payload);
return NextResponse.json({
success: true,
data: result,
});
} catch (error) { } catch (error) {
console.error("[Canvas Generate Image] Error:", error); console.error("[Canvas Generate Image] Error:", error);
return NextResponse.json( return NextResponse.json(

View File

@ -6,13 +6,30 @@ const ALLOWED_FAL_HOSTS = new Set(["rest.alpha.fal.ai", "rest.fal.ai"]);
type FalRealtimeConfig = { type FalRealtimeConfig = {
enabled?: boolean; enabled?: boolean;
falApiKey?: string; falApiKey?: string;
apiKey?: string;
};
type AngleEditConfig = {
falApiKey?: string;
}; };
async function getRealtimeCanvasConfig() { async function getRealtimeCanvasConfig() {
const realtimeSetting = await getSystemSetting({ key: "realtime_canvas_config" }).catch( const [realtimeSetting, angleEditSetting] = await Promise.all([
() => null getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null),
); getSystemSetting({ key: "angle_edit_config" }).catch(() => null),
return (realtimeSetting?.value || {}) as FalRealtimeConfig; ]);
const realtimeConfig = (realtimeSetting?.value || {}) as FalRealtimeConfig;
const angleEditConfig = (angleEditSetting?.value || {}) as AngleEditConfig;
const fallbackKey = String(
angleEditConfig.falApiKey || process.env.FAL_KEY || process.env.FAL_API_KEY || ""
).trim();
return {
...realtimeConfig,
falApiKey:
String(realtimeConfig.falApiKey || realtimeConfig.apiKey || "").trim() || fallbackKey,
} as FalRealtimeConfig;
} }
async function handleProxy(request: Request) { async function handleProxy(request: Request) {

View File

@ -4,6 +4,7 @@ import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = { type RealtimeCanvasConfig = {
enabled?: boolean; enabled?: boolean;
falApiKey?: string; falApiKey?: string;
apiKey?: string;
throttleInterval?: number; throttleInterval?: number;
maxBuffering?: number; maxBuffering?: number;
maxInputResolution?: number; maxInputResolution?: number;
@ -16,6 +17,10 @@ type RealtimeCanvasConfig = {
}; };
}; };
type AngleEditConfig = {
falApiKey?: string;
};
const defaultConfig = { const defaultConfig = {
session: { session: {
idleTimeoutMs: 120000, idleTimeoutMs: 120000,
@ -57,8 +62,12 @@ export async function GET() {
return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 }); return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
} }
const setting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null); const [setting, angleEditSetting] = await Promise.all([
getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null),
getSystemSetting({ key: "angle_edit_config" }).catch(() => null),
]);
const config = (setting?.value || {}) as RealtimeCanvasConfig; const config = (setting?.value || {}) as RealtimeCanvasConfig;
const angleEditConfig = (angleEditSetting?.value || {}) as AngleEditConfig;
if (config.enabled === false) { if (config.enabled === false) {
return Response.json( return Response.json(
@ -67,7 +76,16 @@ export async function GET() {
); );
} }
if (!String(config.falApiKey || "").trim()) { const falApiKey = String(
config.falApiKey ||
config.apiKey ||
angleEditConfig.falApiKey ||
process.env.FAL_KEY ||
process.env.FAL_API_KEY ||
""
).trim();
if (!falApiKey) {
return Response.json( return Response.json(
{ {
code: "FAL_KEY_MISSING", code: "FAL_KEY_MISSING",

View File

@ -336,6 +336,85 @@ const CANVAS_AGENT_SECTION_TITLES = new Set([
"执行计划", "执行计划",
"输出设置", "输出设置",
]); ]);
const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000;
type CanvasImageGenerationRequest = {
prompt: string;
model?: string;
aspectRatio?: string;
imageCount?: number;
negativePrompt?: string;
referenceImage?: string;
referenceImages?: string[];
};
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function requestCanvasImageGeneration(
payload: CanvasImageGenerationRequest
): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> {
const createResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
async: true,
}),
});
const createResult = await createResponse.json().catch(() => ({}));
if (!createResponse.ok || !createResult?.success) {
throw new Error(createResult?.error || "创建生图任务失败");
}
if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) {
return { images: createResult.data.images };
}
const taskId = String(createResult?.taskId || "").trim();
if (!taskId) {
throw new Error("创建生图任务失败:未返回任务ID");
}
const deadline = Date.now() + CANVAS_IMAGE_TASK_TIMEOUT_MS;
let lastError = "";
while (Date.now() < deadline) {
await sleep(CANVAS_IMAGE_TASK_POLL_INTERVAL_MS);
const statusResponse = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`,
{ cache: "no-store" }
);
const statusResult = await statusResponse.json().catch(() => ({}));
if (!statusResponse.ok || !statusResult?.success) {
lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`;
continue;
}
const task = statusResult?.task;
const status = String(task?.status || "").trim();
if (status === "succeeded") {
const result = task?.result || {};
const images = Array.isArray(result?.images) ? result.images : [];
if (images.length === 0) {
throw new Error("任务完成但未返回图片");
}
return { images };
}
if (status === "failed") {
throw new Error(String(task?.error || "图片生成失败"));
}
}
throw new Error(lastError || "图片生成超时,请稍后重试");
}
type CanvasAnalysisSection = { type CanvasAnalysisSection = {
before: string; before: string;
@ -3897,24 +3976,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
...generationReferenceImagesBase, ...generationReferenceImagesBase,
...extraReferenceImages, ...extraReferenceImages,
].filter(Boolean); ].filter(Boolean);
const response = await fetch("/api/canvas/generate/image", { const result = await requestCanvasImageGeneration({
method: "POST", prompt: promptOverride,
headers: { "Content-Type": "application/json" }, model: modelForGeneration,
body: JSON.stringify({ aspectRatio: "9:16",
prompt: promptOverride, imageCount: 1,
model: modelForGeneration, negativePrompt: imageParams.negativePrompt,
aspectRatio: "9:16", referenceImage: productAttachments[0]?.url,
imageCount: 1, referenceImages: generationReferenceImages,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: generationReferenceImages,
}),
}); });
const result = await response.json(); if (!Array.isArray(result?.images) || result.images.length === 0) {
if (!response.ok || !result?.success || !Array.isArray(result?.data?.images) || result.data.images.length === 0) { throw new Error(`第 ${index + 1} 张生成失败`);
throw new Error(result?.error || `第 ${index + 1} 张生成失败`);
} }
const generatedImage = result.data.images[0]; const generatedImage = result.images[0];
return { return {
originalUrl: String(generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "").trim(), originalUrl: String(generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "").trim(),
thumbnailUrl: String(generatedImage?.thumbnailUrl || generatedImage?.originalUrl || "").trim(), thumbnailUrl: String(generatedImage?.thumbnailUrl || generatedImage?.originalUrl || "").trim(),
@ -4148,24 +4222,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []), ...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
].filter(Boolean); ].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", { const reviewResult = await requestCanvasImageGeneration({
method: "POST", prompt: reviewPrompt,
headers: { "Content-Type": "application/json" }, model: modelForGeneration,
body: JSON.stringify({ aspectRatio: "1:1",
prompt: reviewPrompt, imageCount: DETAIL_REVIEW_IMAGE_COUNT,
model: modelForGeneration, negativePrompt: imageParams.negativePrompt,
aspectRatio: "1:1", referenceImage: productAttachments[0]?.url,
imageCount: DETAIL_REVIEW_IMAGE_COUNT, referenceImages: reviewReferenceImages,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
}); });
const reviewResult = await reviewResponse.json(); const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images) if (reviewImagesRaw.length === 0) {
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
reviewFailures.push(`评价${reviewIndex + 1}生成失败`); reviewFailures.push(`评价${reviewIndex + 1}生成失败`);
continue; continue;
} }
@ -4501,24 +4568,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []), ...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
].filter(Boolean); ].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", { const reviewResult = await requestCanvasImageGeneration({
method: "POST", prompt: reviewPrompt,
headers: { "Content-Type": "application/json" }, model: modelForGeneration,
body: JSON.stringify({ aspectRatio: "1:1",
prompt: reviewPrompt, imageCount: DETAIL_REVIEW_IMAGE_COUNT,
model: modelForGeneration, negativePrompt: imageParams.negativePrompt,
aspectRatio: "1:1", referenceImage: productAttachments[0]?.url,
imageCount: DETAIL_REVIEW_IMAGE_COUNT, referenceImages: reviewReferenceImages,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
}); });
const reviewResult = await reviewResponse.json(); const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images) if (reviewImagesRaw.length === 0) {
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
reviewFailures.push(`第${reviewIndex + 1}条生成失败`); reviewFailures.push(`第${reviewIndex + 1}条生成失败`);
continue; continue;
} }

View File

@ -35,6 +35,7 @@ import {
Download, Download,
Undo2, Undo2,
Redo2, Redo2,
Send,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -101,9 +102,15 @@ const defaultRealtimeConfig: RealtimeConfig = {
function buildImageDataUrl(image: any) { function buildImageDataUrl(image: any) {
if (!image) return ""; if (!image) return "";
if (typeof image === "string" && image.trim()) {
return image;
}
if (typeof image.url === "string" && image.url.trim()) { if (typeof image.url === "string" && image.url.trim()) {
return image.url; return image.url;
} }
if (typeof image.image_url === "string" && image.image_url.trim()) {
return image.image_url;
}
if (typeof image.content === "string" && image.content.trim()) { if (typeof image.content === "string" && image.content.trim()) {
if (image.content.startsWith("data:")) { if (image.content.startsWith("data:")) {
return image.content; return image.content;
@ -162,6 +169,7 @@ 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 previewCanvasRef = useRef<HTMLCanvasElement | null>(null);
@ -402,11 +410,11 @@ export function RealtimeCanvasDialog({
}, [saveHistory]); }, [saveHistory]);
const drawInitialImage = useCallback(async () => { const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return; if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return false;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return false;
const image = new Image(); const image = new Image();
image.crossOrigin = "anonymous"; image.crossOrigin = "anonymous";
@ -426,6 +434,7 @@ export function RealtimeCanvasDialog({
hasLoadedInitialImageRef.current = true; hasLoadedInitialImageRef.current = true;
dirtyRef.current = true; dirtyRef.current = true;
saveHistory(); // Save initial image state saveHistory(); // Save initial image state
return true;
}, [initialImageUrl, open, saveHistory]); }, [initialImageUrl, open, saveHistory]);
const initializeCanvas = useCallback(() => { const initializeCanvas = useCallback(() => {
@ -666,8 +675,10 @@ export function RealtimeCanvasDialog({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
initializeCanvas(); initializeCanvas();
void drawInitialImage().then(() => { void drawInitialImage().then((hasInitialImage) => {
scheduleFlush(true, false); if (hasInitialImage) {
scheduleFlush(true, false);
}
}); });
const onResize = () => initializeCanvas(); const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
@ -760,9 +771,14 @@ export function RealtimeCanvasDialog({
return; return;
} }
const latestImage = Array.isArray(result?.images) const imageGroups = [result?.images, result?.output?.images, result?.data?.images];
? result.images[result.images.length - 1] const firstImageGroup = imageGroups.find((group) => Array.isArray(group)) as any[] | undefined;
: null; const latestImage =
(firstImageGroup && firstImageGroup[firstImageGroup.length - 1]) ||
result?.image ||
result?.output?.image ||
result?.data?.image ||
null;
const nextPreviewUrl = buildImageDataUrl(latestImage); const nextPreviewUrl = buildImageDataUrl(latestImage);
if (!nextPreviewUrl) return; if (!nextPreviewUrl) return;
@ -947,6 +963,19 @@ export function RealtimeCanvasDialog({
} }
}, []); }, []);
const handleApplyToCanvas = useCallback(() => {
if (!previewUrl) {
toast.error("请先生成预览结果");
return;
}
onApplyResult({
imageUrl: previewUrl,
prompt: prompt.trim() || FALLBACK_PROMPT,
});
onOpenChange(false);
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
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]">
@ -1259,6 +1288,16 @@ export function RealtimeCanvasDialog({
> >
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'} {status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
</Button> </Button>
<Button
size="sm"
variant="outline"
className="h-9 rounded-xl px-4 font-medium"
onClick={handleApplyToCanvas}
disabled={!previewUrl}
>
<Send className="h-4 w-4 mr-1.5" />
发送到画布
</Button>
</div> </div>
</div> </div>
<div className="text-center mt-2"> <div className="text-center mt-2">

View File

@ -16,10 +16,9 @@ module.exports = {
log_date_format: "YYYY-MM-DD HH:mm:ss Z", log_date_format: "YYYY-MM-DD HH:mm:ss Z",
merge_logs: true, merge_logs: true,
autorestart: true, autorestart: true,
max_memory_restart: "1G", max_memory_restart: "3G",
watch: false, watch: false,
ignore_watch: ["node_modules", ".next", ".git"], ignore_watch: ["node_modules", ".next", ".git"],
}, },
], ],
}; };

View File

@ -0,0 +1,187 @@
import "server-only";
import { getEnabledAiModelsWithProvider, getAiProviderById } from "@/lib/db/queries";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
export interface CanvasGenerateImageRequest {
prompt: string;
negativePrompt?: string;
model?: string;
aspectRatio?: string;
imageCount?: number;
referenceImage?: string;
referenceImages?: string[];
}
export interface ParsedImage {
thumbnailUrl: string;
originalUrl: string;
}
export interface CanvasGenerateImageResult {
images: ParsedImage[];
model: string;
prompt: string;
}
function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...markdown.matchAll(imageRegex)];
const parsed: ParsedImage[] = [];
for (const match of matches) {
const altText = match[1] || "";
const thumbnailUrl = match[2];
let originalUrl = thumbnailUrl;
if (altText.includes("thumb|||")) {
const content = altText.substring(altText.indexOf("thumb|||") + 8);
const parts = content.split("|||");
originalUrl = parts[0] || thumbnailUrl;
} else if (altText.includes("thumb$$")) {
const content = altText.substring(altText.indexOf("thumb$$") + 7);
const parts = content.split("$$");
originalUrl = parts[0] || thumbnailUrl;
} else if (altText.includes("thumb|")) {
const content = altText.substring(altText.indexOf("thumb|") + 6);
const parts = content.split("|");
originalUrl = parts[0] || thumbnailUrl;
}
parsed.push({ thumbnailUrl, originalUrl });
}
return parsed;
}
export async function generateCanvasImage(
request: CanvasGenerateImageRequest
): Promise<CanvasGenerateImageResult> {
const prompt = String(request.prompt || "").trim();
if (!prompt) {
throw new Error("请输入提示词");
}
const negativePrompt =
typeof request.negativePrompt === "string" ? request.negativePrompt : undefined;
const model = typeof request.model === "string" ? request.model : undefined;
const aspectRatio =
typeof request.aspectRatio === "string" && request.aspectRatio.trim()
? request.aspectRatio.trim()
: "1:1";
const imageCount =
typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
? Math.max(1, Math.min(8, Math.floor(request.imageCount)))
: 1;
const referenceImage =
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
const referenceImages = Array.isArray(request.referenceImages)
? request.referenceImages
: [];
const allModels = await getEnabledAiModelsWithProvider();
const imageModels = allModels.filter((m) => m.model.supportsImageGen);
if (!imageModels || imageModels.length === 0) {
throw new Error("暂无可用的图片生成模型");
}
let selectedModel = model
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
if (!selectedModel) {
selectedModel = imageModels[0];
}
const provider = await getAiProviderById({ id: selectedModel.model.providerId });
if (!provider) {
throw new Error("模型提供商配置错误");
}
const normalizedReferenceImages = Array.from(
new Set(
[
...referenceImages,
...(referenceImage ? [referenceImage] : []),
]
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 12);
const messages = [
{
role: "user" as const,
content:
normalizedReferenceImages.length > 0
? [
{ type: "text" as const, text: prompt },
...normalizedReferenceImages.map((url) => ({
type: "image_url" as const,
image_url: { url },
})),
]
: prompt,
},
];
const imageParams = {
aspectRatio,
imageSize: "2K",
imageCount,
negativePrompt,
};
let imageMarkdown = "";
if (provider.type === "gemini") {
const geminiProvider = createGeminiImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = geminiProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
} else if (provider.type === "alibaba-image") {
const alibabaProvider = createAlibabaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = alibabaProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
} else if (provider.type === "ruxa-image") {
const ruxaProvider = createRuxaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = ruxaProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
} else if (provider.type === "fal-image") {
const falProvider = createFalImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = falProvider.chat(selectedModel.model.modelId);
const result = await modelInstance.doGenerate({ messages, imageParams });
imageMarkdown = result.text || "";
} else {
throw new Error("不支持的图片生成模型类型");
}
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
if (parsedImages.length === 0) {
throw new Error("图片生成失败");
}
return {
images: parsedImages,
model: selectedModel.model.displayName,
prompt,
};
}

View File

@ -0,0 +1,238 @@
import "server-only";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasImageTask } from "@/lib/db/schema";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
type CanvasGenerateImageResult,
} from "./generate-image";
type CanvasImageTaskStatus = "pending" | "running" | "succeeded" | "failed";
interface QueueState {
pending: string[];
pendingSet: Set<string>;
running: Set<string>;
draining: boolean;
}
const DEFAULT_CONCURRENCY = 1;
const STALE_RUNNING_MS = 5 * 60 * 1000;
const queueStateKey = "__canvas_image_task_queue_state__";
function getQueueConcurrency(): number {
const raw = Number.parseInt(process.env.CANVAS_IMAGE_TASK_CONCURRENCY || "", 10);
if (Number.isFinite(raw) && raw > 0) {
return Math.min(raw, 4);
}
return DEFAULT_CONCURRENCY;
}
function getQueueState(): QueueState {
const globalObj = globalThis as typeof globalThis & {
[queueStateKey]?: QueueState;
};
if (!globalObj[queueStateKey]) {
globalObj[queueStateKey] = {
pending: [],
pendingSet: new Set<string>(),
running: new Set<string>(),
draining: false,
};
}
return globalObj[queueStateKey] as QueueState;
}
function enqueueLocalTask(taskId: string) {
const state = getQueueState();
if (!taskId || state.running.has(taskId) || state.pendingSet.has(taskId)) {
return;
}
state.pending.push(taskId);
state.pendingSet.add(taskId);
}
async function claimNextPendingTask(): Promise<string | null> {
const state = getQueueState();
while (state.pending.length > 0) {
const taskId = state.pending.shift();
if (!taskId) continue;
state.pendingSet.delete(taskId);
if (!state.running.has(taskId)) {
return taskId;
}
}
const runningIds = Array.from(state.running);
const whereCondition =
runningIds.length > 0
? and(eq(canvasImageTask.status, "pending"), notInArray(canvasImageTask.id, runningIds))
: eq(canvasImageTask.status, "pending");
const rows = await db
.select({ id: canvasImageTask.id })
.from(canvasImageTask)
.where(whereCondition)
.orderBy(asc(canvasImageTask.createdAt))
.limit(1);
return rows[0]?.id ?? null;
}
async function runSingleTask(taskId: string) {
const state = getQueueState();
state.running.add(taskId);
try {
const [task] = await db
.update(canvasImageTask)
.set({
status: "running",
startedAt: new Date(),
updatedAt: new Date(),
completedAt: null,
error: null,
attempts: sql`${canvasImageTask.attempts} + 1`,
})
.where(and(eq(canvasImageTask.id, taskId), eq(canvasImageTask.status, "pending")))
.returning({
id: canvasImageTask.id,
request: canvasImageTask.request,
});
if (!task) {
return;
}
const result = await generateCanvasImage(task.request as CanvasGenerateImageRequest);
await db
.update(canvasImageTask)
.set({
status: "succeeded",
result: result as CanvasGenerateImageResult,
error: null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasImageTask.id, taskId));
} catch (error) {
await db
.update(canvasImageTask)
.set({
status: "failed",
error: error instanceof Error ? error.message : String(error),
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasImageTask.id, taskId));
} finally {
state.running.delete(taskId);
void drainCanvasImageTaskQueue();
}
}
export async function drainCanvasImageTaskQueue() {
const state = getQueueState();
if (state.draining) return;
state.draining = true;
try {
const maxConcurrent = getQueueConcurrency();
while (state.running.size < maxConcurrent) {
const taskId = await claimNextPendingTask();
if (!taskId) break;
if (state.running.has(taskId)) continue;
void runSingleTask(taskId);
}
} finally {
state.draining = false;
}
}
export async function enqueueCanvasImageTask(taskId: string) {
enqueueLocalTask(taskId);
await drainCanvasImageTaskQueue();
}
export async function createCanvasImageTask(params: {
userId: string;
request: CanvasGenerateImageRequest;
}) {
const [task] = await db
.insert(canvasImageTask)
.values({
userId: params.userId,
status: "pending",
request: params.request as unknown as Record<string, unknown>,
attempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({
id: canvasImageTask.id,
status: canvasImageTask.status,
createdAt: canvasImageTask.createdAt,
});
return task;
}
export async function getCanvasImageTaskForUser(params: {
taskId: string;
userId: string;
}) {
const rows = await db
.select({
id: canvasImageTask.id,
status: canvasImageTask.status,
request: canvasImageTask.request,
result: canvasImageTask.result,
error: canvasImageTask.error,
attempts: canvasImageTask.attempts,
startedAt: canvasImageTask.startedAt,
completedAt: canvasImageTask.completedAt,
createdAt: canvasImageTask.createdAt,
updatedAt: canvasImageTask.updatedAt,
})
.from(canvasImageTask)
.where(and(eq(canvasImageTask.id, params.taskId), eq(canvasImageTask.userId, params.userId)))
.limit(1);
const task = rows[0];
if (!task) return null;
if (task.status === "running" && task.startedAt) {
const staleBefore = new Date(Date.now() - STALE_RUNNING_MS);
if (task.startedAt < staleBefore) {
await db
.update(canvasImageTask)
.set({
status: "pending",
startedAt: null,
completedAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(canvasImageTask.id, task.id),
eq(canvasImageTask.status, "running"),
lt(canvasImageTask.startedAt, staleBefore)
)
);
enqueueLocalTask(task.id);
await drainCanvasImageTaskQueue();
task.status = "pending" as CanvasImageTaskStatus;
task.startedAt = null;
task.completedAt = null;
}
}
if (task.status === "pending") {
enqueueLocalTask(task.id);
await drainCanvasImageTaskQueue();
}
return task;
}

View File

@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS "CanvasImageTask" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
"status" varchar(16) NOT NULL DEFAULT 'pending',
"request" jsonb NOT NULL,
"result" jsonb,
"error" text,
"attempts" integer NOT NULL DEFAULT 0,
"startedAt" timestamp,
"completedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "CanvasImageTask_user_status_created_idx"
ON "CanvasImageTask"("userId", "status", "createdAt");
CREATE INDEX IF NOT EXISTS "CanvasImageTask_status_created_idx"
ON "CanvasImageTask"("status", "createdAt");

View File

@ -218,6 +218,13 @@
"when": 1772366460000, "when": 1772366460000,
"tag": "0044_add_resend_config", "tag": "0044_add_resend_config",
"breakpoints": true "breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1772500000000,
"tag": "0045_add_canvas_image_tasks",
"breakpoints": true
} }
] ]
} }

View File

@ -521,6 +521,27 @@ export const canvasDetailArchive = pgTable("CanvasDetailArchive", {
export type CanvasDetailArchive = InferSelectModel<typeof canvasDetailArchive>; export type CanvasDetailArchive = InferSelectModel<typeof canvasDetailArchive>;
// 画布图片生成任务表(异步队列)
export const canvasImageTask = pgTable("CanvasImageTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
status: varchar("status", { length: 16 })
.notNull()
.default("pending"), // pending | running | succeeded | failed
request: jsonb("request").notNull(),
result: jsonb("result"),
error: text("error"),
attempts: integer("attempts").notNull().default(0),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>;
// 画布节点表(可选,用于更细粒度的查询) // 画布节点表(可选,用于更细粒度的查询)
export const canvasNode = pgTable("CanvasNode", { export const canvasNode = pgTable("CanvasNode", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),