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[] = [];
if (chat) {
@ -2598,13 +2598,40 @@ export async function POST(request: Request) {
message,
});
await saveChat({
id,
userId: session.user.id,
title,
visibility: selectedVisibilityType,
});
// New chat - no need to fetch messages, it's empty
try {
await saveChat({
id,
userId: session.user.id,
title,
visibility: selectedVisibilityType,
});
} 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];

View File

@ -1,56 +1,87 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
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";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image";
export const maxDuration = 120;
interface GenerateImageRequest {
prompt: string;
negativePrompt?: string;
model?: string; // 模型 ID
aspectRatio?: string; // 宽高比 "1:1", "16:9" 等
imageCount?: number; // 生成数量
referenceImage?: string; // 参考图片(用于图生图)
referenceImages?: string[]; // 多参考图(用于多角度/细节一致性)
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;
}
interface ParsedImage {
thumbnailUrl: string;
originalUrl: string;
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return {
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[] {
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;
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
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) {
@ -64,160 +95,33 @@ export async function POST(request: Request) {
}
const body: GenerateImageRequest = await request.json();
const {
prompt,
negativePrompt,
model,
aspectRatio = "1:1",
imageCount = 1,
referenceImage,
referenceImages,
} = body;
if (!prompt) {
const payload = toGenerateImagePayload(body);
if (!payload.prompt) {
return NextResponse.json(
{ success: false, error: "请输入提示词" },
{ status: 400 }
);
}
// 获取可用的图片生成模型(筛选 supportsImageGen 的模型)
const allModels = await getEnabledAiModelsWithProvider();
const imageModels = allModels.filter(m => m.model.supportsImageGen);
if (!imageModels || imageModels.length === 0) {
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,
const asyncMode = body.async === true;
if (asyncMode) {
const task = await createCanvasImageTask({
userId: session.user.id,
request: payload,
});
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) {
await enqueueCanvasImageTask(task.id);
return NextResponse.json({
success: true,
data: {
images: parsedImages,
model: selectedModel.model.displayName,
prompt,
}
taskId: task.id,
status: task.status,
});
} else {
return NextResponse.json(
{ success: false, error: "图片生成失败" },
{ status: 500 }
);
}
const result = await generateCanvasImage(payload);
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error("[Canvas Generate Image] Error:", error);
return NextResponse.json(

View File

@ -6,13 +6,30 @@ const ALLOWED_FAL_HOSTS = new Set(["rest.alpha.fal.ai", "rest.fal.ai"]);
type FalRealtimeConfig = {
enabled?: boolean;
falApiKey?: string;
apiKey?: string;
};
type AngleEditConfig = {
falApiKey?: string;
};
async function getRealtimeCanvasConfig() {
const realtimeSetting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(
() => null
);
return (realtimeSetting?.value || {}) as FalRealtimeConfig;
const [realtimeSetting, angleEditSetting] = await Promise.all([
getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null),
getSystemSetting({ key: "angle_edit_config" }).catch(() => null),
]);
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) {

View File

@ -4,6 +4,7 @@ import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = {
enabled?: boolean;
falApiKey?: string;
apiKey?: string;
throttleInterval?: number;
maxBuffering?: number;
maxInputResolution?: number;
@ -16,6 +17,10 @@ type RealtimeCanvasConfig = {
};
};
type AngleEditConfig = {
falApiKey?: string;
};
const defaultConfig = {
session: {
idleTimeoutMs: 120000,
@ -57,8 +62,12 @@ export async function GET() {
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 angleEditConfig = (angleEditSetting?.value || {}) as AngleEditConfig;
if (config.enabled === false) {
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(
{
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 = {
before: string;
@ -3897,24 +3976,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
...generationReferenceImagesBase,
...extraReferenceImages,
].filter(Boolean);
const response = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: promptOverride,
model: modelForGeneration,
aspectRatio: "9:16",
imageCount: 1,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: generationReferenceImages,
}),
const result = await requestCanvasImageGeneration({
prompt: promptOverride,
model: modelForGeneration,
aspectRatio: "9:16",
imageCount: 1,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: generationReferenceImages,
});
const result = await response.json();
if (!response.ok || !result?.success || !Array.isArray(result?.data?.images) || result.data.images.length === 0) {
throw new Error(result?.error || `第 ${index + 1} 张生成失败`);
if (!Array.isArray(result?.images) || result.images.length === 0) {
throw new Error(`第 ${index + 1} 张生成失败`);
}
const generatedImage = result.data.images[0];
const generatedImage = result.images[0];
return {
originalUrl: String(generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "").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) : []),
].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
const reviewResult = await requestCanvasImageGeneration({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
});
const reviewResult = await reviewResponse.json();
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
if (reviewImagesRaw.length === 0) {
reviewFailures.push(`评价${reviewIndex + 1}生成失败`);
continue;
}
@ -4501,24 +4568,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
const reviewResult = await requestCanvasImageGeneration({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
});
const reviewResult = await reviewResponse.json();
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || reviewImagesRaw.length === 0) {
const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
if (reviewImagesRaw.length === 0) {
reviewFailures.push(`第${reviewIndex + 1}条生成失败`);
continue;
}

View File

@ -35,6 +35,7 @@ import {
Download,
Undo2,
Redo2,
Send,
} from "lucide-react";
import { toast } from "sonner";
@ -101,9 +102,15 @@ const defaultRealtimeConfig: RealtimeConfig = {
function buildImageDataUrl(image: any) {
if (!image) return "";
if (typeof image === "string" && image.trim()) {
return image;
}
if (typeof image.url === "string" && image.url.trim()) {
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 (image.content.startsWith("data:")) {
return image.content;
@ -162,6 +169,7 @@ export function RealtimeCanvasDialog({
open,
onOpenChange,
initialImageUrl,
onApplyResult,
}: RealtimeCanvasDialogProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const previewCanvasRef = useRef<HTMLCanvasElement | null>(null);
@ -402,11 +410,11 @@ export function RealtimeCanvasDialog({
}, [saveHistory]);
const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false;
const canvas = canvasRef.current;
if (!canvas) return;
if (!canvas) return false;
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!ctx) return false;
const image = new Image();
image.crossOrigin = "anonymous";
@ -426,6 +434,7 @@ export function RealtimeCanvasDialog({
hasLoadedInitialImageRef.current = true;
dirtyRef.current = true;
saveHistory(); // Save initial image state
return true;
}, [initialImageUrl, open, saveHistory]);
const initializeCanvas = useCallback(() => {
@ -666,8 +675,10 @@ export function RealtimeCanvasDialog({
useEffect(() => {
if (!open) return;
initializeCanvas();
void drawInitialImage().then(() => {
scheduleFlush(true, false);
void drawInitialImage().then((hasInitialImage) => {
if (hasInitialImage) {
scheduleFlush(true, false);
}
});
const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize);
@ -760,9 +771,14 @@ export function RealtimeCanvasDialog({
return;
}
const latestImage = Array.isArray(result?.images)
? result.images[result.images.length - 1]
: null;
const imageGroups = [result?.images, result?.output?.images, result?.data?.images];
const firstImageGroup = imageGroups.find((group) => Array.isArray(group)) as any[] | undefined;
const latestImage =
(firstImageGroup && firstImageGroup[firstImageGroup.length - 1]) ||
result?.image ||
result?.output?.image ||
result?.data?.image ||
null;
const nextPreviewUrl = buildImageDataUrl(latestImage);
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 (
<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]">
@ -1259,6 +1288,16 @@ export function RealtimeCanvasDialog({
>
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
</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 className="text-center mt-2">

View File

@ -16,10 +16,9 @@ module.exports = {
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
merge_logs: true,
autorestart: true,
max_memory_restart: "1G",
max_memory_restart: "3G",
watch: false,
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,
"tag": "0044_add_resend_config",
"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 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", {
id: uuid("id").primaryKey().notNull().defaultRandom(),