feat: queue psd template exports
This commit is contained in:
parent
411bb4a5eb
commit
6648f43a20
@ -1,22 +1,19 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
|
||||
import {
|
||||
AGENT_PSD_RENDER_TASK_POLL_AFTER_MS,
|
||||
agentPsdTemplateRenderRequestSchema,
|
||||
} from "@/lib/agents/psd-render-task-contract";
|
||||
import {
|
||||
createAgentPsdRenderTask,
|
||||
enqueueAgentPsdRenderTask,
|
||||
getAgentPsdRenderTaskForUser,
|
||||
} from "@/lib/agents/psd-render-task-queue";
|
||||
import { describeRemoteAssetLocation } from "@/lib/agents/psd-remote-asset";
|
||||
import {
|
||||
type AgentPsdTemplateRenderResponse,
|
||||
getAgentPsdTemplateRuntimeIssue,
|
||||
getEditableAgentPsdTemplateLayers,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdTemplateReady,
|
||||
isAgentPsdTemplateRuntimeReady,
|
||||
isAgentPsdTextLayerSlot,
|
||||
type AgentPsdTemplateRenderStatus,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
|
||||
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
|
||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
|
||||
import {
|
||||
getFriendlyRateLimitMessage,
|
||||
getUserFacingRequestErrorMessage,
|
||||
@ -25,24 +22,6 @@ import {
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const renderSchema = z.object({
|
||||
templateId: z.string().trim().min(1),
|
||||
sourceImageUrl: z.string().trim().optional().nullable(),
|
||||
imageAssignments: z.record(z.string(), z.string()).default({}),
|
||||
imagePlacements: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
scale: z.number().positive().max(8).optional(),
|
||||
offsetX: z.number().optional(),
|
||||
offsetY: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.default({}),
|
||||
textAssignments: z.record(z.string(), z.string()).default({}),
|
||||
textColorAssignments: z.record(z.string(), z.string()).default({}),
|
||||
});
|
||||
|
||||
function jsonError(status: number, error: string) {
|
||||
const payload: AgentPsdTemplateRenderResponse = {
|
||||
success: false,
|
||||
@ -51,113 +30,102 @@ function jsonError(status: number, error: string) {
|
||||
return NextResponse.json(payload, { status });
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(
|
||||
status: string
|
||||
): Extract<AgentPsdTemplateRenderStatus, "pending" | "running"> {
|
||||
return status === "running" ? "running" : "pending";
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return jsonError(401, "未登录");
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||
if (!taskId) {
|
||||
return jsonError(400, "缺少 taskId 参数");
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await getAgentPsdRenderTaskForUser({
|
||||
taskId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (!task) {
|
||||
return jsonError(404, "导出任务不存在");
|
||||
}
|
||||
|
||||
if (task.status === "succeeded") {
|
||||
const result = task.result as AgentPsdTemplateRenderResponse["result"] | null;
|
||||
if (!result?.url) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
status: "failed",
|
||||
taskId: task.id,
|
||||
error: "导出任务已完成,但未返回结果图",
|
||||
} satisfies AgentPsdTemplateRenderResponse
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: "succeeded",
|
||||
taskId: task.id,
|
||||
result,
|
||||
} satisfies AgentPsdTemplateRenderResponse);
|
||||
}
|
||||
|
||||
if (task.status === "failed") {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
status: "failed",
|
||||
taskId: task.id,
|
||||
error: task.error || "PSD 模板渲染失败",
|
||||
} satisfies AgentPsdTemplateRenderResponse);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processing: true,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
taskId: task.id,
|
||||
pollAfterMs: AGENT_PSD_RENDER_TASK_POLL_AFTER_MS,
|
||||
} satisfies AgentPsdTemplateRenderResponse);
|
||||
} catch (error) {
|
||||
console.error("[PSD Templates] render status failed:", { taskId, error });
|
||||
return jsonError(500, "查询 PSD 导出任务失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return jsonError(401, "未登录");
|
||||
}
|
||||
|
||||
let body: z.infer<typeof renderSchema>;
|
||||
let body: ReturnType<typeof agentPsdTemplateRenderRequestSchema.parse>;
|
||||
try {
|
||||
body = renderSchema.parse(await request.json());
|
||||
body = agentPsdTemplateRenderRequestSchema.parse(await request.json());
|
||||
} catch {
|
||||
return jsonError(400, "渲染参数不合法");
|
||||
}
|
||||
|
||||
try {
|
||||
const templates = await readAgentPsdTemplates();
|
||||
const template = templates.find(
|
||||
(entry) =>
|
||||
entry.id === body.templateId &&
|
||||
entry.status === "active" &&
|
||||
isAgentPsdTemplateReady(entry)
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return jsonError(404, "PSD 模板不存在或未启用");
|
||||
}
|
||||
|
||||
const fonts = await readAgentPsdFonts();
|
||||
const resolvedTemplate = repairAgentPsdTemplateFontBindings(template, fonts);
|
||||
if (!isAgentPsdTemplateRuntimeReady(resolvedTemplate)) {
|
||||
return jsonError(
|
||||
409,
|
||||
getAgentPsdTemplateRuntimeIssue(resolvedTemplate) ||
|
||||
"PSD 模板资源不完整,请在后台重新解析后再导出"
|
||||
);
|
||||
}
|
||||
|
||||
const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate);
|
||||
if (editableLayers.length === 0) {
|
||||
return jsonError(400, "当前模板未配置可编辑图层");
|
||||
}
|
||||
|
||||
const fontMap = new Map(fonts.map((font) => [font.id, font]));
|
||||
const missingFontLayer = editableLayers.find(
|
||||
(layer) => isAgentPsdTextLayerSlot(layer) && layer.fontId && !fontMap.has(layer.fontId)
|
||||
);
|
||||
if (missingFontLayer) {
|
||||
return jsonError(400, `文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
|
||||
}
|
||||
|
||||
const imageAssignments: Record<string, string> = {};
|
||||
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
|
||||
const textAssignments: Record<string, string> = {};
|
||||
const textColorAssignments: Record<string, string> = {};
|
||||
for (const layer of editableLayers) {
|
||||
if (isAgentPsdImageLayerSlot(layer)) {
|
||||
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
|
||||
if (explicitUrl) {
|
||||
imageAssignments[layer.id] = explicitUrl;
|
||||
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
|
||||
imageAssignments[layer.id] = body.sourceImageUrl.trim();
|
||||
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAgentPsdTextLayerSlot(layer)) {
|
||||
const nextText = String(body.textAssignments[layer.id] || layer.defaultText || "").trim();
|
||||
if (nextText) {
|
||||
textAssignments[layer.id] = nextText;
|
||||
}
|
||||
textColorAssignments[layer.id] = normalizeHexColor(
|
||||
body.textColorAssignments[layer.id] || layer.textColor,
|
||||
layer.textColor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const missingRequiredLayer = editableLayers.find(
|
||||
(layer) =>
|
||||
layer.isRequired &&
|
||||
(isAgentPsdImageLayerSlot(layer)
|
||||
? !imageAssignments[layer.id]
|
||||
: !textAssignments[layer.id])
|
||||
);
|
||||
if (missingRequiredLayer) {
|
||||
return jsonError(400, `缺少必传素材:${missingRequiredLayer.layerName}`);
|
||||
}
|
||||
|
||||
const uploaded = await renderAgentPsdTemplate({
|
||||
template: resolvedTemplate,
|
||||
imageAssignments,
|
||||
imagePlacements,
|
||||
textAssignments,
|
||||
textColorAssignments,
|
||||
fonts,
|
||||
const task = await createAgentPsdRenderTask({
|
||||
userId: session.user.id,
|
||||
request: body,
|
||||
});
|
||||
await enqueueAgentPsdRenderTask(task.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result: {
|
||||
url: uploaded.url,
|
||||
pathname: uploaded.pathname,
|
||||
contentType: uploaded.contentType,
|
||||
},
|
||||
processing: true,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
taskId: task.id,
|
||||
pollAfterMs: AGENT_PSD_RENDER_TASK_POLL_AFTER_MS,
|
||||
} satisfies AgentPsdTemplateRenderResponse);
|
||||
} catch (error) {
|
||||
console.error("[PSD Templates] render failed:", {
|
||||
|
||||
@ -80,6 +80,7 @@ import {
|
||||
normalizeHexColor,
|
||||
resolveTextLayout,
|
||||
} from "@/lib/agents/psd-text-layout";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { getFriendlyRateLimitMessage, getUserFacingRequestErrorMessage } from "@/lib/request-error";
|
||||
|
||||
@ -2203,26 +2204,19 @@ export function AgentPsdTemplateEditorDialog({
|
||||
|
||||
try {
|
||||
setIsRendering(true);
|
||||
const response = await fetch("/api/psd-templates/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
setRenderedResultUrl("");
|
||||
const payload = (await requestLongTaskResult({
|
||||
endpoint: "/api/psd-templates/render",
|
||||
timeoutMs: 8 * 60 * 1000,
|
||||
payload: {
|
||||
templateId: selectedTemplate.id,
|
||||
sourceImageUrl: sourceImageUrl || "",
|
||||
imageAssignments: effectiveImageAssignments,
|
||||
imagePlacements: effectiveImagePlacements,
|
||||
textAssignments: effectiveTextAssignments,
|
||||
textColorAssignments: effectiveTextColorAssignments,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await parseActionJsonResponse<AgentPsdTemplateRenderResponse>(
|
||||
response,
|
||||
"PSD 模板渲染失败",
|
||||
getFriendlyRateLimitMessage("当前 PSD 渲染请求过于频繁,请稍后再试")
|
||||
);
|
||||
},
|
||||
})) as AgentPsdTemplateRenderResponse;
|
||||
if (!payload?.success || !payload.result?.url) {
|
||||
throw new Error(payload?.error || "PSD 模板渲染失败");
|
||||
}
|
||||
|
||||
123
lib/agents/psd-render-service.ts
Normal file
123
lib/agents/psd-render-service.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import "server-only";
|
||||
|
||||
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
|
||||
import {
|
||||
type AgentPsdTemplateRenderRequest,
|
||||
} from "@/lib/agents/psd-render-task-contract";
|
||||
import {
|
||||
type AgentPsdTemplateRenderResult,
|
||||
getAgentPsdTemplateRuntimeIssue,
|
||||
getEditableAgentPsdTemplateLayers,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdTemplateReady,
|
||||
isAgentPsdTemplateRuntimeReady,
|
||||
isAgentPsdTextLayerSlot,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
|
||||
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
|
||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
|
||||
|
||||
export async function executeAgentPsdTemplateRenderRequest(
|
||||
body: AgentPsdTemplateRenderRequest
|
||||
): Promise<AgentPsdTemplateRenderResult> {
|
||||
const templates = await readAgentPsdTemplates();
|
||||
const template = templates.find(
|
||||
(entry) =>
|
||||
entry.id === body.templateId &&
|
||||
entry.status === "active" &&
|
||||
isAgentPsdTemplateReady(entry)
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
throw new Error("PSD 模板不存在或未启用");
|
||||
}
|
||||
|
||||
const fonts = await readAgentPsdFonts();
|
||||
const resolvedTemplate = repairAgentPsdTemplateFontBindings(template, fonts);
|
||||
if (!isAgentPsdTemplateRuntimeReady(resolvedTemplate)) {
|
||||
throw new Error(
|
||||
getAgentPsdTemplateRuntimeIssue(resolvedTemplate) ||
|
||||
"PSD 模板资源不完整,请在后台重新解析后再导出"
|
||||
);
|
||||
}
|
||||
|
||||
const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate);
|
||||
if (editableLayers.length === 0) {
|
||||
throw new Error("当前模板未配置可编辑图层");
|
||||
}
|
||||
|
||||
const fontMap = new Map(fonts.map((font) => [font.id, font]));
|
||||
const missingFontLayer = editableLayers.find(
|
||||
(layer) => isAgentPsdTextLayerSlot(layer) && layer.fontId && !fontMap.has(layer.fontId)
|
||||
);
|
||||
if (missingFontLayer) {
|
||||
throw new Error(`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
|
||||
}
|
||||
|
||||
const imageAssignments: Record<string, string> = {};
|
||||
const imagePlacements: Record<
|
||||
string,
|
||||
ReturnType<typeof normalizeAgentPsdImagePlacement>
|
||||
> = {};
|
||||
const textAssignments: Record<string, string> = {};
|
||||
const textColorAssignments: Record<string, string> = {};
|
||||
|
||||
for (const layer of editableLayers) {
|
||||
if (isAgentPsdImageLayerSlot(layer)) {
|
||||
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
|
||||
if (explicitUrl) {
|
||||
imageAssignments[layer.id] = explicitUrl;
|
||||
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(
|
||||
body.imagePlacements[layer.id]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
|
||||
imageAssignments[layer.id] = body.sourceImageUrl.trim();
|
||||
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(
|
||||
body.imagePlacements[layer.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAgentPsdTextLayerSlot(layer)) {
|
||||
const nextText = String(body.textAssignments[layer.id] || layer.defaultText || "").trim();
|
||||
if (nextText) {
|
||||
textAssignments[layer.id] = nextText;
|
||||
}
|
||||
textColorAssignments[layer.id] = normalizeHexColor(
|
||||
body.textColorAssignments[layer.id] || layer.textColor,
|
||||
layer.textColor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const missingRequiredLayer = editableLayers.find(
|
||||
(layer) =>
|
||||
layer.isRequired &&
|
||||
(isAgentPsdImageLayerSlot(layer)
|
||||
? !imageAssignments[layer.id]
|
||||
: !textAssignments[layer.id])
|
||||
);
|
||||
if (missingRequiredLayer) {
|
||||
throw new Error(`缺少必传素材:${missingRequiredLayer.layerName}`);
|
||||
}
|
||||
|
||||
const uploaded = await renderAgentPsdTemplate({
|
||||
template: resolvedTemplate,
|
||||
imageAssignments,
|
||||
imagePlacements,
|
||||
textAssignments,
|
||||
textColorAssignments,
|
||||
fonts,
|
||||
});
|
||||
|
||||
return {
|
||||
url: uploaded.url,
|
||||
pathname: uploaded.pathname,
|
||||
contentType: uploaded.contentType,
|
||||
};
|
||||
}
|
||||
25
lib/agents/psd-render-task-contract.ts
Normal file
25
lib/agents/psd-render-task-contract.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AGENT_PSD_RENDER_TASK_POLL_AFTER_MS = 1500;
|
||||
|
||||
export const agentPsdTemplateRenderRequestSchema = z.object({
|
||||
templateId: z.string().trim().min(1),
|
||||
sourceImageUrl: z.string().trim().optional().nullable(),
|
||||
imageAssignments: z.record(z.string(), z.string()).default({}),
|
||||
imagePlacements: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
scale: z.number().positive().max(8).optional(),
|
||||
offsetX: z.number().optional(),
|
||||
offsetY: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.default({}),
|
||||
textAssignments: z.record(z.string(), z.string()).default({}),
|
||||
textColorAssignments: z.record(z.string(), z.string()).default({}),
|
||||
});
|
||||
|
||||
export type AgentPsdTemplateRenderRequest = z.infer<
|
||||
typeof agentPsdTemplateRenderRequestSchema
|
||||
>;
|
||||
243
lib/agents/psd-render-task-queue.ts
Normal file
243
lib/agents/psd-render-task-queue.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
|
||||
import { executeAgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-service";
|
||||
import type { AgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-task-contract";
|
||||
import { db } from "@/lib/db";
|
||||
import { agentPsdRenderTask } from "@/lib/db/schema";
|
||||
|
||||
type AgentPsdRenderTaskStatus = "pending" | "running" | "succeeded" | "failed";
|
||||
|
||||
interface QueueState {
|
||||
pending: string[];
|
||||
pendingSet: Set<string>;
|
||||
running: Set<string>;
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONCURRENCY = 1;
|
||||
const STALE_RUNNING_MS = 15 * 60 * 1000;
|
||||
const queueStateKey = "__agent_psd_render_task_queue_state__";
|
||||
|
||||
function getQueueConcurrency(): number {
|
||||
const raw = Number.parseInt(process.env.AGENT_PSD_RENDER_TASK_CONCURRENCY || "", 10);
|
||||
if (Number.isFinite(raw) && raw > 0) {
|
||||
return Math.min(raw, 3);
|
||||
}
|
||||
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(agentPsdRenderTask.status, "pending"), notInArray(agentPsdRenderTask.id, runningIds))
|
||||
: eq(agentPsdRenderTask.status, "pending");
|
||||
|
||||
const rows = await db
|
||||
.select({ id: agentPsdRenderTask.id })
|
||||
.from(agentPsdRenderTask)
|
||||
.where(whereCondition)
|
||||
.orderBy(asc(agentPsdRenderTask.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(agentPsdRenderTask)
|
||||
.set({
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
error: null,
|
||||
updatedAt: new Date(),
|
||||
attempts: sql`${agentPsdRenderTask.attempts} + 1`,
|
||||
})
|
||||
.where(and(eq(agentPsdRenderTask.id, taskId), eq(agentPsdRenderTask.status, "pending")))
|
||||
.returning({
|
||||
id: agentPsdRenderTask.id,
|
||||
request: agentPsdRenderTask.request,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await executeAgentPsdTemplateRenderRequest(
|
||||
task.request as AgentPsdTemplateRenderRequest
|
||||
);
|
||||
await db
|
||||
.update(agentPsdRenderTask)
|
||||
.set({
|
||||
status: "succeeded",
|
||||
result: result as unknown as Record<string, unknown>,
|
||||
error: null,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agentPsdRenderTask.id, taskId));
|
||||
} catch (error) {
|
||||
await db
|
||||
.update(agentPsdRenderTask)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agentPsdRenderTask.id, taskId));
|
||||
} finally {
|
||||
state.running.delete(taskId);
|
||||
void drainAgentPsdRenderTaskQueue();
|
||||
}
|
||||
}
|
||||
|
||||
export async function drainAgentPsdRenderTaskQueue() {
|
||||
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 enqueueAgentPsdRenderTask(taskId: string) {
|
||||
enqueueLocalTask(taskId);
|
||||
await drainAgentPsdRenderTaskQueue();
|
||||
}
|
||||
|
||||
export async function createAgentPsdRenderTask(params: {
|
||||
userId: string;
|
||||
request: AgentPsdTemplateRenderRequest;
|
||||
}) {
|
||||
const [task] = await db
|
||||
.insert(agentPsdRenderTask)
|
||||
.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: agentPsdRenderTask.id,
|
||||
status: agentPsdRenderTask.status,
|
||||
createdAt: agentPsdRenderTask.createdAt,
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function getAgentPsdRenderTaskForUser(params: {
|
||||
taskId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: agentPsdRenderTask.id,
|
||||
status: agentPsdRenderTask.status,
|
||||
request: agentPsdRenderTask.request,
|
||||
result: agentPsdRenderTask.result,
|
||||
error: agentPsdRenderTask.error,
|
||||
attempts: agentPsdRenderTask.attempts,
|
||||
startedAt: agentPsdRenderTask.startedAt,
|
||||
completedAt: agentPsdRenderTask.completedAt,
|
||||
createdAt: agentPsdRenderTask.createdAt,
|
||||
updatedAt: agentPsdRenderTask.updatedAt,
|
||||
})
|
||||
.from(agentPsdRenderTask)
|
||||
.where(
|
||||
and(eq(agentPsdRenderTask.id, params.taskId), eq(agentPsdRenderTask.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(agentPsdRenderTask)
|
||||
.set({
|
||||
status: "pending",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(agentPsdRenderTask.id, task.id),
|
||||
eq(agentPsdRenderTask.status, "running"),
|
||||
lt(agentPsdRenderTask.startedAt, staleBefore)
|
||||
)
|
||||
);
|
||||
enqueueLocalTask(task.id);
|
||||
await drainAgentPsdRenderTaskQueue();
|
||||
task.status = "pending" as AgentPsdRenderTaskStatus;
|
||||
task.startedAt = null;
|
||||
task.completedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (task.status === "pending") {
|
||||
enqueueLocalTask(task.id);
|
||||
await drainAgentPsdRenderTaskQueue();
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
@ -133,8 +133,18 @@ export interface AgentPsdTemplateRenderResult {
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export type AgentPsdTemplateRenderStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
export interface AgentPsdTemplateRenderResponse {
|
||||
success: boolean;
|
||||
processing?: boolean;
|
||||
status?: AgentPsdTemplateRenderStatus;
|
||||
taskId?: string;
|
||||
pollAfterMs?: number;
|
||||
result?: AgentPsdTemplateRenderResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
19
lib/db/migrations/0068_add_agent_psd_render_tasks.sql
Normal file
19
lib/db/migrations/0068_add_agent_psd_render_tasks.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS "AgentPsdRenderTask" (
|
||||
"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 "AgentPsdRenderTask_user_status_created_idx"
|
||||
ON "AgentPsdRenderTask"("userId", "status", "createdAt");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "AgentPsdRenderTask_status_created_idx"
|
||||
ON "AgentPsdRenderTask"("status", "createdAt");
|
||||
@ -379,6 +379,13 @@
|
||||
"when": 1775462400000,
|
||||
"tag": "0067_add_ai_cad_multi_workspace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "7",
|
||||
"when": 1775782800000,
|
||||
"tag": "0068_add_agent_psd_render_tasks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -739,6 +739,26 @@ export const canvasImageTask = pgTable("CanvasImageTask", {
|
||||
|
||||
export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>;
|
||||
|
||||
export const agentPsdRenderTask = pgTable("AgentPsdRenderTask", {
|
||||
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 AgentPsdRenderTask = InferSelectModel<typeof agentPsdRenderTask>;
|
||||
|
||||
// 画布节点表(可选,用于更细粒度的查询)
|
||||
export const canvasNode = pgTable("CanvasNode", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user