fix: async canvas image generation queue and stabilize runtime
This commit is contained in:
parent
d8a9e15426
commit
598cdfd736
@ -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];
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
187
lib/canvas/generate-image.ts
Normal file
187
lib/canvas/generate-image.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
238
lib/canvas/image-task-queue.ts
Normal file
238
lib/canvas/image-task-queue.ts
Normal 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;
|
||||||
|
}
|
||||||
19
lib/db/migrations/0045_add_canvas_image_tasks.sql
Normal file
19
lib/db/migrations/0045_add_canvas_image_tasks.sql
Normal 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");
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user