fix(agent): make detail replacement results survive page and server restarts
详情替换原来把整批生图挂在浏览器保持的对话流上,切换标签页、断网或连接中断后 结果就再也回不来,前端还会继续播放按时间推算的 96% 假进度。 现在改为服务器端后台批次:提交时先落任务,再按参考图逐张生成并把每张结果写进 项目,页面刷新、切页或服务重启后都能按任务状态恢复,不再依赖前端连接。 - 新增 /api/agents/detail-replacement/batches(提交/查询、幂等去重、重试上限) - 前端只在服务端确认批次后显示处理中,卡片显示排队/生成/完成/失败真实状态 - 去掉假进度动画;没有产品输入图时提交前直接拦截 - 服务重启后自动恢复未完成批次,长批次不再被通用 30 分钟清理误判 - 单张失败只落到该卡片并继续后续图片,已完成的图片不重复生成和扣费 - 详情替换默认模型由持续 404 的 gpt-image-2.5-flare 改回 gpt-image-2.5
This commit is contained in:
parent
1c2acee547
commit
0907aaff67
@ -364,7 +364,10 @@ import {
|
||||
resolveReplacementReferenceGeometry,
|
||||
resolveReplacementScenePlanningRuntime,
|
||||
} from "./_lib/detail-replacement";
|
||||
import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs";
|
||||
import {
|
||||
selectDetailReplacementGenerationInputs,
|
||||
shouldIncludeDetailReplacementModelImage,
|
||||
} from "@/lib/ai/detail-replacement-inputs";
|
||||
import {
|
||||
normalizeAgentReportForDisplay,
|
||||
sanitizeAgentOutputSettingsSection,
|
||||
@ -419,8 +422,6 @@ const chatApiLogger = createLogger("chat.route");
|
||||
|
||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||
|
||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||
const normalizedMarkdown = String(markdown || "").trim();
|
||||
@ -443,35 +444,6 @@ function getPublicImageGenerationErrorText(error: unknown) {
|
||||
return message;
|
||||
}
|
||||
|
||||
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
|
||||
if (!scenePlan) return false;
|
||||
if (scenePlan.useModelImage === true) return true;
|
||||
if (
|
||||
scenePlan.peoplePolicy &&
|
||||
scenePlan.peoplePolicy !== "none"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (scenePlan.generationRoute === "people_relation_locked") return true;
|
||||
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = [
|
||||
scenePlan.modelImageReason,
|
||||
scenePlan.routeReason,
|
||||
scenePlan.sceneSummary,
|
||||
scenePlan.cameraSummary,
|
||||
scenePlan.placementSummary,
|
||||
scenePlan.preserveSummary,
|
||||
scenePlan.styleSummary,
|
||||
scenePlan.avoidSummary,
|
||||
scenePlan.generationPrompt,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
|
||||
}
|
||||
|
||||
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||
1,
|
||||
@ -5940,7 +5912,7 @@ export async function POST(request: Request) {
|
||||
const shouldIncludeModelImage =
|
||||
detailReplacementStrategy !== "product_only" &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
(shouldIncludeDetailReplacementModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
selectedProductContextImageUrls.length === 0);
|
||||
@ -6833,7 +6805,7 @@ export async function POST(request: Request) {
|
||||
!(isDetailReplacementPreset &&
|
||||
detailReplacementStrategy === "product_only") &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
(shouldIncludeDetailReplacementModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
(isDetailReplacementPreset &&
|
||||
|
||||
294
app/api/agents/detail-replacement/batches/route.ts
Normal file
294
app/api/agents/detail-replacement/batches/route.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
ensureCanvasImageTaskActive,
|
||||
getCanvasImageTaskForUser,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
checkUserImageQuota,
|
||||
getChatById,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
saveChat,
|
||||
} from "@/lib/db/queries";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
||||
import {
|
||||
buildAgentSlotPrompt,
|
||||
normalizeAgentSupplementPrompt,
|
||||
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
|
||||
import {
|
||||
detailReplacementSubmissionKey,
|
||||
isDetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchReference,
|
||||
type DetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchResult,
|
||||
} from "@/lib/agents/detail-replacement-batch";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function scopedChatId(projectId: string) {
|
||||
const normalized = projectId.toLowerCase().replace(/-/g, "");
|
||||
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
|
||||
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
|
||||
}
|
||||
|
||||
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
|
||||
const result = task?.result as DetailReplacementBatchResult | null;
|
||||
const request = task?.request as DetailReplacementBatchRequest | null;
|
||||
return {
|
||||
id: task?.id,
|
||||
status: task?.status,
|
||||
attempts: task?.attempts,
|
||||
error: task?.error || null,
|
||||
cards: Array.isArray(result?.cards) ? result.cards : [],
|
||||
currentCardId: result?.currentCardId || null,
|
||||
requestedCardIds: request?.references?.map((item) => item.id) || [],
|
||||
createdAt: task?.createdAt || null,
|
||||
queueStatus: task?.queueStatus || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findActiveBatchId(params: {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
submissionKey: string;
|
||||
}) {
|
||||
const [row] = await db.select({ id: canvasImageTask.id })
|
||||
.from(canvasImageTask)
|
||||
.where(and(
|
||||
eq(canvasImageTask.userId, params.userId),
|
||||
inArray(canvasImageTask.status, ["pending", "running"]),
|
||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
||||
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
|
||||
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
|
||||
))
|
||||
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
|
||||
return row?.id || null;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown) {
|
||||
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
|
||||
if ((cursor as { code?: unknown }).code === "23505") return true;
|
||||
cursor = (cursor as { cause?: unknown }).cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const params = new URL(request.url).searchParams;
|
||||
const projectId = String(params.get("projectId") || "").trim();
|
||||
const taskId = String(params.get("taskId") || "").trim();
|
||||
if (!UUID_RE.test(projectId)) return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
|
||||
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
|
||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
||||
)).limit(1);
|
||||
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
||||
|
||||
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
|
||||
.from(canvasImageTask)
|
||||
.where(and(
|
||||
eq(canvasImageTask.userId, session.user.id),
|
||||
...(taskId && UUID_RE.test(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
|
||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
||||
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
|
||||
))
|
||||
// A batch that is still working matters more than a newer finished one, so the
|
||||
// workspace keeps showing live progress after a rerun of a single card.
|
||||
.orderBy(
|
||||
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
|
||||
desc(canvasImageTask.createdAt),
|
||||
).limit(1);
|
||||
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
|
||||
return NextResponse.json({ task: null });
|
||||
}
|
||||
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
||||
if (task && (task.status === "failed" ||
|
||||
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
|
||||
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
|
||||
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
||||
}
|
||||
return NextResponse.json({ task: taskResponse(task) });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const projectId = String(body.projectId || "").trim();
|
||||
const taskId = String(body.requestId || "").trim();
|
||||
if (!UUID_RE.test(projectId) || !UUID_RE.test(taskId)) {
|
||||
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
|
||||
}
|
||||
const [project] = await db.select().from(canvasProject).where(and(
|
||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
||||
)).limit(1);
|
||||
if (!project || !isAgentProjectRow(project)) {
|
||||
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
|
||||
const knownReferences = new Map(
|
||||
(state.workflowState.agentModeState?.detailReplacement?.references || [])
|
||||
.map((item) => [String(item.id), String(item.imageUrl)]),
|
||||
);
|
||||
const requested = Array.isArray(body.references) ? body.references : [];
|
||||
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
|
||||
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
||||
return {
|
||||
id: String(item.id || "").slice(0, 128),
|
||||
label: String(item.label || "详情参考图").slice(0, 255),
|
||||
imageUrl: String(item.imageUrl || "").trim(),
|
||||
candidateId: String(item.candidateId || "").slice(0, 128) || null,
|
||||
passthrough: item.passthrough === true,
|
||||
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
|
||||
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
|
||||
: [],
|
||||
};
|
||||
});
|
||||
if (
|
||||
references.length === 0 || references.length > 40 ||
|
||||
new Set(references.map((item) => item.id)).size !== references.length ||
|
||||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
|
||||
) {
|
||||
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
|
||||
}
|
||||
const productImageUrls = Array.isArray(body.productImageUrls)
|
||||
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
|
||||
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
|
||||
: [];
|
||||
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
|
||||
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
|
||||
}
|
||||
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
|
||||
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
|
||||
if (strategy !== "product_only" && !prompt) {
|
||||
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
|
||||
}
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const model = await resolveDirectReplacementModelRef({
|
||||
selectedChatModel: String(body.model || "auto"),
|
||||
finalModelId: String(body.model || "auto"),
|
||||
models,
|
||||
});
|
||||
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
|
||||
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
|
||||
}
|
||||
const quota = await checkUserImageQuota(session.user.id);
|
||||
if (!quota.allowed) {
|
||||
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
||||
}
|
||||
const requestedImageCount = references.filter((item) => !item.passthrough).length;
|
||||
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
|
||||
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
|
||||
}
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
|
||||
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
|
||||
const unitCost = getModelGenerationCreditCost(
|
||||
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
|
||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
||||
});
|
||||
const user = await getUserById({ id: session.user.id });
|
||||
const expectedCost = unitCost * requestedImageCount;
|
||||
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
|
||||
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
|
||||
}
|
||||
}
|
||||
const chatId = scopedChatId(projectId);
|
||||
const existingChat = await getChatById({ id: chatId });
|
||||
if (existingChat && existingChat.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
|
||||
}
|
||||
if (!existingChat) await saveChat({
|
||||
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
|
||||
});
|
||||
const taskRequest: DetailReplacementBatchRequest = {
|
||||
taskKind: "agent-detail-replacement-batch",
|
||||
projectId,
|
||||
chatId,
|
||||
model,
|
||||
prompt: strategy === "product_only"
|
||||
? "仅替换参考详情图中的产品主体"
|
||||
: buildAgentSlotPrompt({
|
||||
workflowPrompt: prompt,
|
||||
slotPrompt: "",
|
||||
userSupplementPrompt: normalizeAgentSupplementPrompt(
|
||||
String(body.userPrompt || ""), "详情参考图替换",
|
||||
),
|
||||
includeSlotTitle: false,
|
||||
modeType: "detail_replacement",
|
||||
}),
|
||||
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
|
||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
||||
replacementStrategy: strategy,
|
||||
productImageUrls,
|
||||
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
|
||||
? String(body.modelImageUrl) : null,
|
||||
references,
|
||||
};
|
||||
const submissionKey = detailReplacementSubmissionKey(taskRequest);
|
||||
taskRequest.submissionKey = submissionKey;
|
||||
|
||||
const duplicateId = await findActiveBatchId({
|
||||
userId: session.user.id, projectId, submissionKey,
|
||||
});
|
||||
if (duplicateId) {
|
||||
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
|
||||
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
|
||||
return NextResponse.json({ task: taskResponse(duplicate) });
|
||||
}
|
||||
|
||||
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
|
||||
try {
|
||||
task = await createCanvasImageTask({
|
||||
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
// Two identical submissions raced past the lookup above; the database
|
||||
// unique index kept one of them, so answer with that batch.
|
||||
const racedId = await findActiveBatchId({
|
||||
userId: session.user.id, projectId, submissionKey,
|
||||
});
|
||||
if (!racedId) throw error;
|
||||
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
|
||||
return NextResponse.json({ task: taskResponse(raced) });
|
||||
}
|
||||
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
|
||||
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
|
||||
if (
|
||||
!isDetailReplacementBatchRequest(storedRequest) ||
|
||||
storedRequest.projectId !== projectId ||
|
||||
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
|
||||
) {
|
||||
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
|
||||
}
|
||||
await db.insert(message).values({
|
||||
id: taskId,
|
||||
chatId,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
}).onConflictDoNothing({ target: message.id });
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
||||
return NextResponse.json({ task: taskResponse(current) });
|
||||
} catch (error) {
|
||||
console.error("[Detail Replacement Batch] Failed to start", error);
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import {
|
||||
Fragment,
|
||||
@ -2564,10 +2565,23 @@ interface LocalGenerationPendingState {
|
||||
expectedCount: number;
|
||||
userTurnCountAtStart: number;
|
||||
requestMessageId?: string | null;
|
||||
backendTaskId?: string | null;
|
||||
timestamp: number;
|
||||
requestedCardIds?: string[] | null;
|
||||
}
|
||||
|
||||
type DetailReplacementBatchTaskView = {
|
||||
id: string;
|
||||
status: "pending" | "running" | "succeeded" | "failed";
|
||||
error: string | null;
|
||||
attempts: number;
|
||||
cards: DetailReplacementBatchCard[];
|
||||
currentCardId: string | null;
|
||||
requestedCardIds: string[];
|
||||
createdAt: string;
|
||||
queueStatus?: { queuePosition?: number } | null;
|
||||
};
|
||||
|
||||
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
||||
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
||||
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
||||
@ -2619,6 +2633,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
||||
expectedCount: Number(record.expectedCount),
|
||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
|
||||
timestamp: Number(record.timestamp),
|
||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||
? record.requestedCardIds
|
||||
@ -2651,16 +2666,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
||||
}
|
||||
}
|
||||
|
||||
const DETAIL_RESULT_PROCESSING_STAGES = [
|
||||
"解码图像",
|
||||
"定位选区",
|
||||
"分离主体",
|
||||
"重绘背景",
|
||||
"融合边缘",
|
||||
"检查细节",
|
||||
"输出结果",
|
||||
];
|
||||
|
||||
function DetailResultProcessingScanOverlay({
|
||||
imageUrl,
|
||||
label,
|
||||
@ -2668,29 +2673,6 @@ function DetailResultProcessingScanOverlay({
|
||||
imageUrl?: string;
|
||||
label: string;
|
||||
}) {
|
||||
const [progress, setProgress] = useState(6);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
const durationMs = 240_000;
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const linear = Math.min(1, elapsed / durationMs);
|
||||
const eased = 1 - Math.pow(1 - linear, 1.8);
|
||||
const drift = Math.sin(elapsed / 3200) * 1.2;
|
||||
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
|
||||
setProgress((current) => Math.max(current, next));
|
||||
}, 720);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [imageUrl, label]);
|
||||
|
||||
const stageIndex = Math.min(
|
||||
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
|
||||
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
|
||||
);
|
||||
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||
<style>{`
|
||||
@ -2804,26 +2786,6 @@ function DetailResultProcessingScanOverlay({
|
||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
|
||||
style={{
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
|
||||
}}
|
||||
>
|
||||
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
|
||||
<span>{stageName}</span>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -3166,6 +3128,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
||||
const detailGenerationCancelRef = useRef(false);
|
||||
const workspaceStateSignatureRef = useRef<string>("");
|
||||
const detailBatchSubmittingRef = useRef(false);
|
||||
const reportedDetailBatchFailureRef = useRef<string | null>(null);
|
||||
const generationRequestSnapshotRef = useRef<{
|
||||
hiddenPrompt: string | null;
|
||||
replacementExecutionCardIds: string[] | null;
|
||||
@ -3500,6 +3464,75 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||
const isReplacementPreset =
|
||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
||||
const { data: detailBatchData, mutate: refreshDetailBatch } = useSWR<{
|
||||
task: DetailReplacementBatchTaskView | null;
|
||||
}>(
|
||||
projectId && currentResultModeType === "detail_replacement"
|
||||
? `/api/agents/detail-replacement/batches?projectId=${encodeURIComponent(projectId)}`
|
||||
: null,
|
||||
fetcher,
|
||||
{ refreshInterval: 3000, revalidateOnFocus: true },
|
||||
);
|
||||
const detailBatchTask = detailBatchData?.task || null;
|
||||
useEffect(() => {
|
||||
if (!detailBatchData || currentResultModeType !== "detail_replacement") return;
|
||||
const task = detailBatchData.task;
|
||||
if (task && (task.status === "pending" || task.status === "running")) {
|
||||
setPendingGeneration((current) => {
|
||||
if (current?.backendTaskId === task.id) return current;
|
||||
return {
|
||||
chatId,
|
||||
requestMessageId: task.id,
|
||||
backendTaskId: task.id,
|
||||
modeType: "detail_replacement",
|
||||
expectedCount: task.requestedCardIds.length,
|
||||
requestedCardIds: task.requestedCardIds,
|
||||
userTurnCountAtStart: 0,
|
||||
timestamp: Date.parse(task.createdAt) || Date.now(),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setPendingGeneration((current) =>
|
||||
current?.modeType === "detail_replacement" &&
|
||||
(!current.backendTaskId || current.backendTaskId === task?.id)
|
||||
? null
|
||||
: current,
|
||||
);
|
||||
}
|
||||
}, [chatId, currentResultModeType, detailBatchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const cards = detailBatchTask?.cards || [];
|
||||
if (cards.length === 0) return;
|
||||
setReplacementResultState((current) => {
|
||||
const next = { ...current.detail_replacement };
|
||||
let changed = false;
|
||||
for (const card of cards) {
|
||||
const reference = detailReplacementReferenceCards.find((item) => item.id === card.cardId);
|
||||
if (!reference || !card.imageUrl || next[card.cardId]?.url === card.imageUrl) continue;
|
||||
next[card.cardId] = {
|
||||
url: card.imageUrl,
|
||||
text: card.text,
|
||||
status: card.status,
|
||||
updatedAt: Date.now(),
|
||||
compareSourceUrl: reference.imageUrl,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
label: reference.label,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
return changed ? { ...current, detail_replacement: next } : current;
|
||||
});
|
||||
}, [detailBatchTask?.cards, detailReplacementReferenceCards]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
detailBatchTask?.status !== "failed" ||
|
||||
detailBatchTask.attempts < 3 ||
|
||||
reportedDetailBatchFailureRef.current === detailBatchTask.id
|
||||
) return;
|
||||
reportedDetailBatchFailureRef.current = detailBatchTask.id;
|
||||
toast.error(detailBatchTask.error || "详情替换批次中断,已保留生成成功的图片,可继续处理剩余图片");
|
||||
}, [detailBatchTask]);
|
||||
const isProductOnlyDetailReplacementMode =
|
||||
currentResultModeType === "detail_replacement" &&
|
||||
detailReplacementStrategy === "product_only";
|
||||
@ -4996,7 +5029,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
Array.isArray(pendingGeneration.requestedCardIds)
|
||||
? pendingGeneration.requestedCardIds.length
|
||||
: null,
|
||||
status: getReplacementCardStatus({
|
||||
status: detailBatchTask?.requestedCardIds.includes(card.id)
|
||||
? resultMeta?.url
|
||||
? resultMeta.status
|
||||
: detailBatchTask.status === "running"
|
||||
? detailBatchTask.currentCardId === card.id ? "generating" : "queued"
|
||||
: detailBatchTask.status === "pending"
|
||||
? "queued"
|
||||
: detailBatchTask.status === "failed"
|
||||
? "missing"
|
||||
: "waiting"
|
||||
: getReplacementCardStatus({
|
||||
index,
|
||||
resultUrl,
|
||||
resultText,
|
||||
@ -5010,6 +5053,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}),
|
||||
[
|
||||
detailReplacementReferenceCards,
|
||||
detailBatchTask,
|
||||
isDetailResultModeGenerating,
|
||||
replacementRunRecovery.completedCardIds,
|
||||
latestReplacementResults,
|
||||
@ -5164,7 +5208,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const detailRemainingCount = useMemo(
|
||||
() =>
|
||||
detailResultCards.filter(
|
||||
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
|
||||
(card) => !["completed", "passthrough"].includes(card.status)
|
||||
).length,
|
||||
[detailResultCards]
|
||||
);
|
||||
@ -5439,7 +5483,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [currentResultModeType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || !replacementRunRecovery.done) return;
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||
stop();
|
||||
transportGenerationStartedAtRef.current = null;
|
||||
@ -5447,7 +5491,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration) {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5457,7 +5501,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || !chatError) {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5472,7 +5516,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [chatError, clearError, pendingGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration) {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -6198,7 +6242,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const processedStatuses = new Set<ReplacementCardStatus>([
|
||||
"completed",
|
||||
"passthrough",
|
||||
"fallback",
|
||||
]);
|
||||
const remainingIds = resultCards
|
||||
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
||||
@ -6304,6 +6347,82 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
||||
return;
|
||||
}
|
||||
if (replacementModeType === "detail_replacement") {
|
||||
if (detailBatchSubmittingRef.current) return;
|
||||
const productImageUrls = Array.from(new Set(
|
||||
(detailReplacementProductSourceImages.length > 0
|
||||
? detailReplacementProductSourceImages.map((item) => item.url)
|
||||
: selectedProductAttachments.map((item) => item.url))
|
||||
.filter((url) => isValidChatHttpUrl(url)),
|
||||
));
|
||||
if (productImageUrls.length === 0 && !replacementExecutionCards.every((card) => card.passthrough)) {
|
||||
toast.error("请先在输入图区选择至少一张产品图");
|
||||
return;
|
||||
}
|
||||
detailBatchSubmittingRef.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const saved = await onPersistProject?.();
|
||||
if (saved === false) throw new Error("项目尚未保存,请重试");
|
||||
const requestId = generateUUID();
|
||||
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
requestId,
|
||||
model: resolvedModelId,
|
||||
imageSize: imageParams.imageSize,
|
||||
replacementStrategy: detailReplacementStrategy,
|
||||
prompt: detailReplacementStrategy === "product_only"
|
||||
? "仅替换参考详情图中的产品主体"
|
||||
: String(resolvedWorkflowContext?.promptText || "").trim(),
|
||||
userPrompt: String(options?.promptText || input || "").trim(),
|
||||
productImageUrls,
|
||||
modelImageUrl: detailReplacementModelSourceImage?.url || selectedModelAttachment?.url || null,
|
||||
references: replacementExecutionCards.map((card) => ({
|
||||
id: card.id,
|
||||
label: card.label,
|
||||
imageUrl: card.imageUrl,
|
||||
candidateId: card.candidateId,
|
||||
passthrough: card.passthrough,
|
||||
rerunReferenceImages:
|
||||
options?.replacementRerunReferenceImages?.cardId === card.id
|
||||
? options.replacementRerunReferenceImages.images
|
||||
: [],
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !payload?.task?.id) {
|
||||
throw new Error(payload?.error || "详情替换任务提交失败");
|
||||
}
|
||||
setPendingGeneration({
|
||||
chatId,
|
||||
requestMessageId: requestId,
|
||||
backendTaskId: payload.task.id,
|
||||
modeType: "detail_replacement",
|
||||
expectedCount: replacementExecutionCards.length,
|
||||
userTurnCountAtStart: userMessageCount,
|
||||
timestamp: Date.now(),
|
||||
requestedCardIds: replacementExecutionCards.map((card) => card.id),
|
||||
});
|
||||
setReplacementExecutionContext({
|
||||
modeType: "detail_replacement",
|
||||
cardIds: replacementExecutionCards.map((card) => card.id),
|
||||
});
|
||||
setActiveResultModeType("detail_replacement");
|
||||
setSelectedResultUrl(null);
|
||||
await refreshDetailBatch();
|
||||
toast.success(`已提交 ${replacementExecutionCards.length} 张详情图,离开页面后仍会继续处理`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "详情替换任务提交失败");
|
||||
} finally {
|
||||
detailBatchSubmittingRef.current = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
||||
const sanitizedAttachments = sceneMessageAttachments;
|
||||
if (
|
||||
@ -6453,6 +6572,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
detailResultCards,
|
||||
detailReplacementReferenceCards.length,
|
||||
detailReplacementStrategy,
|
||||
detailReplacementProductSourceImages,
|
||||
detailReplacementModelSourceImage,
|
||||
handleRunAiDetailGeneration,
|
||||
currentResultModeType,
|
||||
getReplacementExecutionCards,
|
||||
@ -6483,6 +6604,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
scenePrimaryReferenceCount,
|
||||
sceneSecondaryReferenceCount,
|
||||
selectedModelAttachment,
|
||||
selectedProductAttachments,
|
||||
onPersistProject,
|
||||
projectId,
|
||||
refreshDetailBatch,
|
||||
]);
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
detailGenerationCancelRef.current = true;
|
||||
@ -9339,7 +9464,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isAnyGenerationRunning ? (
|
||||
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@ -2,4 +2,17 @@ import { registerOTel } from "@vercel/otel";
|
||||
|
||||
export function register() {
|
||||
registerOTel({ serviceName: "ai-chatbot" });
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const globalState = globalThis as typeof globalThis & { detailReplacementRecoveryStarted?: boolean };
|
||||
if (globalState.detailReplacementRecoveryStarted) return;
|
||||
globalState.detailReplacementRecoveryStarted = true;
|
||||
|
||||
const recover = () => {
|
||||
void import("@/lib/canvas/image-task-queue")
|
||||
.then(({ resumeDetailReplacementBatchTasks }) => resumeDetailReplacementBatchTasks())
|
||||
.catch((error) => console.error("[Detail Replacement Batch] Recovery failed", error));
|
||||
};
|
||||
setTimeout(recover, 5000).unref?.();
|
||||
setInterval(recover, 60_000).unref?.();
|
||||
}
|
||||
|
||||
@ -2,10 +2,16 @@ import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
||||
import { appendAgentProjectResultForSave } from "@/lib/agents/agent-project-save-merge";
|
||||
import {
|
||||
appendAgentProjectResultForSave,
|
||||
mergeAgentProjectStateForSave,
|
||||
} from "@/lib/agents/agent-project-save-merge";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import type { AgentGenerationResultModeType } from "@/types/agent";
|
||||
import type {
|
||||
AgentGenerationReplacementResultRecord,
|
||||
AgentGenerationResultModeType,
|
||||
} from "@/types/agent";
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@ -65,3 +71,75 @@ export async function persistAgentProjectGeneratedResult(params: {
|
||||
return Boolean(updatedProject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAgentDetailReplacementCard(params: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
cardId: string;
|
||||
imageUrl: string;
|
||||
referenceImageUrl: string;
|
||||
label: string;
|
||||
status: AgentGenerationReplacementResultRecord["status"];
|
||||
text: string;
|
||||
}) {
|
||||
if (
|
||||
!UUID_PATTERN.test(params.projectId) ||
|
||||
!UUID_PATTERN.test(params.userId) ||
|
||||
!params.cardId ||
|
||||
!/^https?:\/\//i.test(params.imageUrl)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [project] = await tx
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, params.projectId), eq(canvasProject.userId, params.userId)))
|
||||
.limit(1)
|
||||
.for("update");
|
||||
if (!project || !isAgentProjectRow(project)) return false;
|
||||
|
||||
const existingState = extractAgentProjectStateFromCanvasData(project.canvasData);
|
||||
const record: AgentGenerationReplacementResultRecord = {
|
||||
url: params.imageUrl,
|
||||
text: params.text,
|
||||
status: params.status,
|
||||
updatedAt: Date.now(),
|
||||
compareSourceUrl: params.referenceImageUrl,
|
||||
referenceImageUrl: params.referenceImageUrl,
|
||||
label: params.label,
|
||||
};
|
||||
const nextState = mergeAgentProjectStateForSave({
|
||||
existingState,
|
||||
incomingState: {
|
||||
...existingState,
|
||||
resultCollections: {
|
||||
...existingState.resultCollections,
|
||||
detail_replacement: [
|
||||
...(existingState.resultCollections?.detail_replacement || []),
|
||||
params.imageUrl,
|
||||
],
|
||||
},
|
||||
replacementResultState: {
|
||||
...existingState.replacementResultState,
|
||||
detail_replacement: {
|
||||
...existingState.replacementResultState?.detail_replacement,
|
||||
[params.cardId]: record,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const canvasData = getCanvasDataRecord(project.canvasData);
|
||||
await tx.update(canvasProject).set({
|
||||
canvasData: {
|
||||
...canvasData,
|
||||
nodes: Array.isArray(canvasData.nodes) ? canvasData.nodes : [],
|
||||
agentProjectState: nextState,
|
||||
} as typeof canvasProject.$inferInsert.canvasData,
|
||||
thumbnail: project.thumbnail || params.imageUrl,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(canvasProject.id, params.projectId));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
295
lib/agents/detail-replacement-batch-runner.ts
Normal file
295
lib/agents/detail-replacement-batch-runner.ts
Normal file
@ -0,0 +1,295 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasImageTask, canvasProject, generatedImage, message } from "@/lib/db/schema";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
||||
import {
|
||||
buildAgentToolImagesMarkdown,
|
||||
prependImageModelBadge,
|
||||
} from "@/lib/ai/runtime/agent-media-markdown";
|
||||
import {
|
||||
selectDetailReplacementGenerationInputs,
|
||||
shouldIncludeDetailReplacementModelImage,
|
||||
} from "@/lib/ai/detail-replacement-inputs";
|
||||
import { isRemoteImageAccessible } from "@/app/(chat)/api/chat/_lib/image-media";
|
||||
import {
|
||||
analyzeReplacementExecutionPlan,
|
||||
buildAgentReplacementPrompt,
|
||||
isMarketplaceProductDetailReferenceId,
|
||||
normalizeReplacementResultImage,
|
||||
resolveReplacementReferenceGeometry,
|
||||
resolveReplacementScenePlanningRuntime,
|
||||
} from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
||||
import { persistAgentDetailReplacementCard } from "@/lib/agents/agent-project-result-persistence";
|
||||
import { extractAgentProjectStateFromCanvasData } from "@/lib/agents/agent-project-compat";
|
||||
import {
|
||||
completedDetailReplacementCardIds,
|
||||
detailReplacementCardMessageId,
|
||||
type DetailReplacementBatchCard,
|
||||
type DetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchResult,
|
||||
} from "@/lib/agents/detail-replacement-batch";
|
||||
|
||||
export async function executeDetailReplacementBatch(params: {
|
||||
taskId: string;
|
||||
userId: string;
|
||||
request: DetailReplacementBatchRequest;
|
||||
}): Promise<DetailReplacementBatchResult> {
|
||||
const { taskId, userId, request } = params;
|
||||
const [storedTask] = await db.select({ result: canvasImageTask.result })
|
||||
.from(canvasImageTask)
|
||||
.where(and(eq(canvasImageTask.id, taskId), eq(canvasImageTask.userId, userId)))
|
||||
.limit(1);
|
||||
const storedResult = storedTask?.result as DetailReplacementBatchResult | null;
|
||||
const cards = Array.isArray(storedResult?.cards) ? [...storedResult.cards] : [];
|
||||
const completedIds = completedDetailReplacementCardIds(storedResult);
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const planningRuntime = await resolveReplacementScenePlanningRuntime({ models });
|
||||
|
||||
const publish = async (currentCardId: string | null) => {
|
||||
const result: DetailReplacementBatchResult = {
|
||||
taskKind: "agent-detail-replacement-batch",
|
||||
cards: [...cards],
|
||||
currentCardId,
|
||||
};
|
||||
await db.update(canvasImageTask).set({ result, updatedAt: new Date() })
|
||||
.where(and(eq(canvasImageTask.id, taskId), eq(canvasImageTask.status, "running")));
|
||||
return result;
|
||||
};
|
||||
|
||||
const persistAssistantAndAlbum = async (params: {
|
||||
card: DetailReplacementBatchCard;
|
||||
text: string;
|
||||
prompt: string;
|
||||
sourceImageUrls: string[];
|
||||
}) => {
|
||||
const messageId = detailReplacementCardMessageId(taskId, params.card.cardId);
|
||||
await db.insert(message).values({
|
||||
id: messageId,
|
||||
chatId: request.chatId,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: params.text }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: message.id,
|
||||
set: { parts: [{ type: "text", text: params.text }], createdAt: new Date() },
|
||||
});
|
||||
if (params.card.status !== "completed") return;
|
||||
const [existingImage] = await db.select({ id: generatedImage.id })
|
||||
.from(generatedImage)
|
||||
.where(and(eq(generatedImage.messageId, messageId), eq(generatedImage.userId, userId)))
|
||||
.limit(1);
|
||||
if (existingImage) return;
|
||||
await saveGeneratedImages(
|
||||
params.text, userId, request.chatId, messageId, params.prompt, request.model,
|
||||
{
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
relationType: "edited_from",
|
||||
sourceType: "detail_replacement",
|
||||
projectId: request.projectId,
|
||||
userPrompt: request.userPrompt,
|
||||
optimizedPrompt: params.prompt,
|
||||
requestedImageCount: 1,
|
||||
aspectRatio: "auto",
|
||||
imageSize: request.imageSize,
|
||||
},
|
||||
).catch((error) => {
|
||||
console.error("[Detail Replacement Batch] Album persistence failed", {
|
||||
taskId, cardId: params.card.cardId, error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const [index, reference] of request.references.entries()) {
|
||||
if (completedIds.has(reference.id)) continue;
|
||||
await publish(reference.id);
|
||||
const startedAt = Date.now();
|
||||
let card: DetailReplacementBatchCard;
|
||||
let sourceImageUrls = [...request.productImageUrls, reference.imageUrl];
|
||||
let prompt = request.prompt;
|
||||
const [storedProject] = await db.select({ canvasData: canvasProject.canvasData })
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, request.projectId), eq(canvasProject.userId, userId)))
|
||||
.limit(1);
|
||||
const recoveredRecord = extractAgentProjectStateFromCanvasData(storedProject?.canvasData)
|
||||
.replacementResultState?.detail_replacement?.[reference.id];
|
||||
if (
|
||||
recoveredRecord?.url &&
|
||||
recoveredRecord.status !== "fallback" &&
|
||||
recoveredRecord.text.includes(`<!-- AGENT_REPLACEMENT_RUN_ID:${taskId} -->`)
|
||||
) {
|
||||
card = {
|
||||
cardId: reference.id,
|
||||
status: recoveredRecord.status,
|
||||
imageUrl: recoveredRecord.url,
|
||||
thumbnailUrl: recoveredRecord.url,
|
||||
text: recoveredRecord.text,
|
||||
};
|
||||
await persistAssistantAndAlbum({
|
||||
card, text: recoveredRecord.text, prompt, sourceImageUrls,
|
||||
});
|
||||
const previousIndex = cards.findIndex((item) => item.cardId === card.cardId);
|
||||
if (previousIndex >= 0) cards.splice(previousIndex, 1);
|
||||
cards.push(card);
|
||||
completedIds.add(reference.id);
|
||||
await publish(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (reference.passthrough) {
|
||||
card = {
|
||||
cardId: reference.id,
|
||||
status: "passthrough",
|
||||
imageUrl: reference.imageUrl,
|
||||
thumbnailUrl: reference.imageUrl,
|
||||
text: "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。",
|
||||
};
|
||||
} else {
|
||||
const plan = await analyzeReplacementExecutionPlan({
|
||||
modeType: "detail_replacement",
|
||||
replacementStrategy: request.replacementStrategy,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
referenceLabel: reference.label,
|
||||
productImageUrls: request.productImageUrls,
|
||||
modelImageUrl: request.modelImageUrl,
|
||||
workflowPrompt: request.replacementStrategy === "product_only" ? null : request.prompt,
|
||||
planningRuntime,
|
||||
models,
|
||||
});
|
||||
const geometry = await resolveReplacementReferenceGeometry(reference.imageUrl);
|
||||
const selectedIndexes = plan.scenePlan.selectedProductImageIndexes
|
||||
.map((value) => value - 1)
|
||||
.filter((value) => value >= 0 && value < request.productImageUrls.length);
|
||||
const selectedProducts = selectedIndexes.length > 0
|
||||
? selectedIndexes.map((value) => request.productImageUrls[value]!)
|
||||
: request.productImageUrls.slice(0, 2);
|
||||
const inputs = selectDetailReplacementGenerationInputs({
|
||||
productImageUrls: selectedProducts,
|
||||
modelImageUrl: request.modelImageUrl,
|
||||
includeModelImage: Boolean(
|
||||
request.replacementStrategy !== "product_only" &&
|
||||
request.modelImageUrl &&
|
||||
(shouldIncludeDetailReplacementModelImage(plan.scenePlan) ||
|
||||
selectedProducts.length === 0),
|
||||
),
|
||||
rerunReferenceImageUrls: reference.rerunReferenceImages?.map((image) => image.url),
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
});
|
||||
sourceImageUrls = inputs.imageUrls;
|
||||
prompt = buildAgentReplacementPrompt({
|
||||
basePrompt: request.prompt,
|
||||
modeType: "detail_replacement",
|
||||
replacementStrategy: request.replacementStrategy,
|
||||
marketplaceProductDetailReference:
|
||||
isMarketplaceProductDetailReferenceId(reference.id) ||
|
||||
isMarketplaceProductDetailReferenceId(reference.candidateId),
|
||||
referenceLabel: reference.label,
|
||||
referenceIndex: index,
|
||||
totalReferences: request.references.length,
|
||||
hasModelImage: Boolean(inputs.modelImageUrl),
|
||||
productImageCount: inputs.productImageUrls.length,
|
||||
originalProductImageCount: request.productImageUrls.length,
|
||||
rerunReferenceCount: inputs.supplementImageUrls.length,
|
||||
templateReferenceCount: 0,
|
||||
scenePlan: plan.scenePlan,
|
||||
referenceGeometry: geometry,
|
||||
});
|
||||
const generated = await generateCanvasImage({
|
||||
prompt,
|
||||
model: request.model,
|
||||
ownerKey: userId,
|
||||
aspectRatio: geometry?.aspectRatio || "auto",
|
||||
imageSize: request.imageSize,
|
||||
imageCount: 1,
|
||||
referenceImages: inputs.imageUrls,
|
||||
promptExtend: false,
|
||||
providerImageParams: { inputImageRoleByUrl: inputs.inputImageRoleByUrl },
|
||||
debugLabel: "agent-detail-replacement-batch",
|
||||
debugMeta: { projectId: request.projectId, cardId: reference.id },
|
||||
});
|
||||
const image = generated.images[0];
|
||||
if (!image?.originalUrl) throw new Error("模型未返回结果图片");
|
||||
let finalImage = image;
|
||||
try {
|
||||
finalImage = await normalizeReplacementResultImage({
|
||||
generatedImageUrl: image.originalUrl,
|
||||
generatedThumbnailUrl: image.thumbnailUrl,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
referenceGeometry: geometry,
|
||||
scenePlan: plan.scenePlan,
|
||||
preserveReferenceOutsideSubject: request.replacementStrategy === "product_only",
|
||||
preserveReferenceText: true,
|
||||
uploadPrefix: "agent-detail-replacement",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[Detail Replacement Batch] Normalize failed, retaining generated image", {
|
||||
taskId, cardId: reference.id, error,
|
||||
});
|
||||
if (!await isRemoteImageAccessible(image.originalUrl).catch(() => false)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
card = {
|
||||
cardId: reference.id,
|
||||
status: "completed",
|
||||
imageUrl: finalImage.originalUrl,
|
||||
thumbnailUrl: finalImage.thumbnailUrl || finalImage.originalUrl,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "生成失败";
|
||||
console.error("[Detail Replacement Batch] Card failed", {
|
||||
taskId, cardId: reference.id, elapsedMs: Date.now() - startedAt, error,
|
||||
});
|
||||
card = {
|
||||
cardId: reference.id,
|
||||
status: "fallback",
|
||||
imageUrl: reference.imageUrl,
|
||||
thumbnailUrl: reference.imageUrl,
|
||||
text: `生成失败,当前先沿用参考图:${message}`,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
|
||||
const assistantText = prependImageModelBadge(
|
||||
[
|
||||
`<!-- AGENT_REPLACEMENT_CARD_ID:${reference.id} -->`,
|
||||
`<!-- AGENT_REPLACEMENT_RUN_ID:${taskId} -->`,
|
||||
`<!-- AGENT_REPLACEMENT_STATUS:${card.status === "completed" ? "completed" : "failed"} -->`,
|
||||
`### ${reference.label}`,
|
||||
card.text,
|
||||
buildAgentToolImagesMarkdown([{
|
||||
originalUrl: card.imageUrl,
|
||||
thumbnailUrl: card.thumbnailUrl,
|
||||
imageSize: request.imageSize,
|
||||
}]),
|
||||
].filter(Boolean).join("\n\n"),
|
||||
card.status === "completed" ? request.model : null,
|
||||
);
|
||||
|
||||
const persisted = await persistAgentDetailReplacementCard({
|
||||
projectId: request.projectId,
|
||||
userId,
|
||||
cardId: reference.id,
|
||||
imageUrl: card.imageUrl,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
label: reference.label,
|
||||
status: card.status,
|
||||
text: assistantText,
|
||||
});
|
||||
if (!persisted) throw new Error(`无法保存详情替换结果:${reference.id}`);
|
||||
|
||||
await persistAssistantAndAlbum({ card, text: assistantText, prompt, sourceImageUrls });
|
||||
const previousIndex = cards.findIndex((item) => item.cardId === card.cardId);
|
||||
if (previousIndex >= 0) cards.splice(previousIndex, 1);
|
||||
cards.push(card);
|
||||
completedIds.add(reference.id);
|
||||
await publish(null);
|
||||
}
|
||||
return publish(null);
|
||||
}
|
||||
105
lib/agents/detail-replacement-batch.ts
Normal file
105
lib/agents/detail-replacement-batch.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type DetailReplacementBatchReference = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageUrl: string;
|
||||
candidateId?: string | null;
|
||||
passthrough?: boolean;
|
||||
rerunReferenceImages?: Array<{ url: string }>;
|
||||
};
|
||||
|
||||
export type DetailReplacementBatchRequest = {
|
||||
taskKind: "agent-detail-replacement-batch";
|
||||
projectId: string;
|
||||
chatId: string;
|
||||
submissionKey?: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
userPrompt: string;
|
||||
imageSize: string;
|
||||
replacementStrategy: "product_only" | "scene_replace";
|
||||
productImageUrls: string[];
|
||||
modelImageUrl: string | null;
|
||||
references: DetailReplacementBatchReference[];
|
||||
};
|
||||
|
||||
export type DetailReplacementBatchCard = {
|
||||
cardId: string;
|
||||
status: "completed" | "fallback" | "passthrough";
|
||||
imageUrl: string;
|
||||
thumbnailUrl: string;
|
||||
text: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DetailReplacementBatchResult = {
|
||||
taskKind: "agent-detail-replacement-batch";
|
||||
cards: DetailReplacementBatchCard[];
|
||||
currentCardId: string | null;
|
||||
};
|
||||
|
||||
export function isDetailReplacementBatchRequest(
|
||||
value: unknown,
|
||||
): value is DetailReplacementBatchRequest {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
(value as { taskKind?: unknown }).taskKind === "agent-detail-replacement-batch",
|
||||
);
|
||||
}
|
||||
|
||||
export function completedDetailReplacementCardIds(
|
||||
result: DetailReplacementBatchResult | null | undefined,
|
||||
) {
|
||||
return new Set(
|
||||
(result?.cards || [])
|
||||
.filter((card) => card.status === "completed" || card.status === "passthrough")
|
||||
.map((card) => card.cardId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of one user submission. Two clicks that ask for exactly the same
|
||||
* work produce the same key, so the second one is answered with the running
|
||||
* batch instead of submitting (and billing) the same images twice. A different
|
||||
* card selection - for example re-running a single finished card while a batch
|
||||
* is still going - produces a different key and is allowed to start.
|
||||
*/
|
||||
export function detailReplacementSubmissionKey(input: {
|
||||
projectId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
userPrompt: string;
|
||||
imageSize: string;
|
||||
replacementStrategy: string;
|
||||
productImageUrls: string[];
|
||||
modelImageUrl: string | null;
|
||||
references: DetailReplacementBatchReference[];
|
||||
}) {
|
||||
const payload = JSON.stringify({
|
||||
projectId: input.projectId,
|
||||
model: input.model,
|
||||
prompt: input.prompt,
|
||||
userPrompt: input.userPrompt,
|
||||
imageSize: input.imageSize,
|
||||
strategy: input.replacementStrategy,
|
||||
products: [...input.productImageUrls].sort(),
|
||||
modelImage: input.modelImageUrl || "",
|
||||
cards: [...input.references]
|
||||
.map((item) => [
|
||||
item.id,
|
||||
item.imageUrl,
|
||||
item.passthrough ? 1 : 0,
|
||||
(item.rerunReferenceImages || []).map((entry) => entry.url).join(","),
|
||||
])
|
||||
.sort((left, right) => String(left[0]).localeCompare(String(right[0]))),
|
||||
});
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export function detailReplacementCardMessageId(taskId: string, cardId: string) {
|
||||
const hex = createHash("sha256").update(`${taskId}:${cardId}`).digest("hex").slice(0, 32);
|
||||
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join("-");
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
|
||||
"detail_replacement_image_model_config";
|
||||
|
||||
export const DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID = "gpt-image-2.5-flare";
|
||||
export const DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID = "gpt-image-2.5";
|
||||
|
||||
export type DetailReplacementImageModelConfig = {
|
||||
modelId: string;
|
||||
|
||||
@ -4,6 +4,42 @@ export type DetailReplacementGenerationInputRole =
|
||||
| "supplement"
|
||||
| "layout";
|
||||
|
||||
const MODEL_IMAGE_TEXT_HINT =
|
||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||
|
||||
export function shouldIncludeDetailReplacementModelImage(scenePlan: {
|
||||
useModelImage?: boolean;
|
||||
peoplePolicy?: string;
|
||||
generationRoute?: string;
|
||||
pageKind?: string;
|
||||
modelImageReason?: string;
|
||||
routeReason?: string;
|
||||
sceneSummary?: string;
|
||||
cameraSummary?: string;
|
||||
placementSummary?: string;
|
||||
preserveSummary?: string;
|
||||
styleSummary?: string;
|
||||
avoidSummary?: string;
|
||||
generationPrompt?: string;
|
||||
} | null | undefined) {
|
||||
if (!scenePlan) return false;
|
||||
if (scenePlan.useModelImage) return true;
|
||||
if (scenePlan.peoplePolicy && scenePlan.peoplePolicy !== "none") return true;
|
||||
if (scenePlan.generationRoute === "people_relation_locked") return true;
|
||||
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") return true;
|
||||
return MODEL_IMAGE_TEXT_HINT.test([
|
||||
scenePlan.modelImageReason,
|
||||
scenePlan.routeReason,
|
||||
scenePlan.sceneSummary,
|
||||
scenePlan.cameraSummary,
|
||||
scenePlan.placementSummary,
|
||||
scenePlan.preserveSummary,
|
||||
scenePlan.styleSummary,
|
||||
scenePlan.avoidSummary,
|
||||
scenePlan.generationPrompt,
|
||||
].filter(Boolean).join(" "));
|
||||
}
|
||||
|
||||
export function selectDetailReplacementGenerationInputs(params: {
|
||||
productImageUrls: string[];
|
||||
modelImageUrl?: string | null;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, desc, eq, lt, notInArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, lt, notInArray, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasImageTask } from "@/lib/db/schema";
|
||||
import {
|
||||
@ -31,6 +31,12 @@ import {
|
||||
markCanvasLayoutReplaceBatchItemRunning,
|
||||
} from "@/lib/canvas/layout-replace-batch";
|
||||
import { persistAgentProjectGeneratedResult } from "@/lib/agents/agent-project-result-persistence";
|
||||
import {
|
||||
isDetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchResult,
|
||||
} from "@/lib/agents/detail-replacement-batch";
|
||||
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
||||
|
||||
type CanvasImageTaskStatus =
|
||||
| "waiting"
|
||||
@ -41,8 +47,9 @@ type CanvasImageTaskStatus =
|
||||
type CanvasImageTaskRequest =
|
||||
| CanvasGenerateImageRequest
|
||||
| StyleProTaskRequest
|
||||
| CanvasLayoutReplaceTaskRequest;
|
||||
type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult;
|
||||
| CanvasLayoutReplaceTaskRequest
|
||||
| DetailReplacementBatchRequest;
|
||||
type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult | DetailReplacementBatchResult;
|
||||
|
||||
export type CanvasImageTaskQueueStatus = {
|
||||
status: CanvasImageTaskStatus;
|
||||
@ -129,6 +136,7 @@ function getRecord(value: unknown): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
function getCanvasImageRunningTimeoutMs(request: unknown) {
|
||||
if (isDetailReplacementBatchRequest(request)) return 3 * 60 * 60 * 1000;
|
||||
const record = getRecord(request);
|
||||
const debugMeta = getRecord(record?.debugMeta);
|
||||
const debugLabel = String(record?.debugLabel || "");
|
||||
@ -425,16 +433,19 @@ async function markStaleDatabaseRunningTasksFailed() {
|
||||
|
||||
const localRunningIds = Array.from(state.running.keys());
|
||||
const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
|
||||
const nonDetailBatch = sql`coalesce(${canvasImageTask.request}->>'taskKind', '') <> 'agent-detail-replacement-batch'`;
|
||||
const staleWhere =
|
||||
localRunningIds.length > 0
|
||||
? and(
|
||||
eq(canvasImageTask.status, "running"),
|
||||
lt(canvasImageTask.startedAt, staleBefore),
|
||||
nonDetailBatch,
|
||||
notInArray(canvasImageTask.id, localRunningIds)
|
||||
)
|
||||
: and(
|
||||
eq(canvasImageTask.status, "running"),
|
||||
lt(canvasImageTask.startedAt, staleBefore)
|
||||
lt(canvasImageTask.startedAt, staleBefore),
|
||||
nonDetailBatch
|
||||
);
|
||||
|
||||
const completedAt = new Date(now);
|
||||
@ -502,6 +513,12 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
request,
|
||||
userId: String(task.userId || entry.userId),
|
||||
})
|
||||
: isDetailReplacementBatchRequest(request)
|
||||
? executeDetailReplacementBatch({
|
||||
taskId: task.id,
|
||||
userId: String(task.userId || entry.userId),
|
||||
request,
|
||||
})
|
||||
: isCanvasLayoutReplaceTaskRequest(request)
|
||||
? executeCanvasLayoutReplaceTask({
|
||||
request,
|
||||
@ -521,11 +538,25 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
request: imageRequest,
|
||||
});
|
||||
})();
|
||||
const result = await withCanvasImageTaskExecutionTimeout(
|
||||
execution,
|
||||
runningTimeoutMs,
|
||||
entry.taskId
|
||||
);
|
||||
const heartbeat = isDetailReplacementBatchRequest(request)
|
||||
? setInterval(() => {
|
||||
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
||||
.where(and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running")))
|
||||
.catch((error) => {
|
||||
console.warn("[Canvas Image Task Queue] Batch heartbeat failed:", error);
|
||||
});
|
||||
}, 30_000)
|
||||
: null;
|
||||
let result: CanvasImageTaskResult;
|
||||
try {
|
||||
result = await withCanvasImageTaskExecutionTimeout(
|
||||
execution,
|
||||
runningTimeoutMs,
|
||||
entry.taskId
|
||||
);
|
||||
} finally {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
}
|
||||
completedBatchResult = result;
|
||||
if (isCanvasLayoutReplaceTaskRequest(request) && request.agentProjectId) {
|
||||
const layoutReplaceResult = result as CanvasGenerateImageResult;
|
||||
@ -641,6 +672,8 @@ export async function ensureCanvasImageTaskActive(params: {
|
||||
request: canvasImageTask.request,
|
||||
result: canvasImageTask.result,
|
||||
error: canvasImageTask.error,
|
||||
attempts: canvasImageTask.attempts,
|
||||
updatedAt: canvasImageTask.updatedAt,
|
||||
})
|
||||
.from(canvasImageTask)
|
||||
.where(
|
||||
@ -653,6 +686,22 @@ export async function ensureCanvasImageTaskActive(params: {
|
||||
if (!task || task.status === "waiting") return false;
|
||||
|
||||
const request = task.request as CanvasImageTaskRequest;
|
||||
if (
|
||||
task.status === "failed" &&
|
||||
isDetailReplacementBatchRequest(request) &&
|
||||
task.attempts < 3
|
||||
) {
|
||||
await db.update(canvasImageTask).set({
|
||||
status: "pending",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(canvasImageTask.id, task.id), eq(canvasImageTask.status, "failed")));
|
||||
await enqueueLocalTask(task.id, { userId: params.userId });
|
||||
await drainCanvasImageTaskQueue();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(task.status === "succeeded" || task.status === "failed") &&
|
||||
isCanvasLayoutReplaceTaskRequest(request) &&
|
||||
@ -671,7 +720,11 @@ export async function ensureCanvasImageTaskActive(params: {
|
||||
}
|
||||
|
||||
if (task.status === "running" && !getQueueState().running.has(task.id)) {
|
||||
await db
|
||||
if (
|
||||
isDetailReplacementBatchRequest(request) &&
|
||||
Date.now() - task.updatedAt.getTime() < 90_000
|
||||
) return false;
|
||||
const [requeued] = await db
|
||||
.update(canvasImageTask)
|
||||
.set({
|
||||
status: "pending",
|
||||
@ -683,9 +736,13 @@ export async function ensureCanvasImageTaskActive(params: {
|
||||
.where(
|
||||
and(
|
||||
eq(canvasImageTask.id, task.id),
|
||||
eq(canvasImageTask.status, "running")
|
||||
eq(canvasImageTask.status, "running"),
|
||||
...(isDetailReplacementBatchRequest(request)
|
||||
? [sql`${canvasImageTask.updatedAt} <= ${task.updatedAt}`]
|
||||
: []),
|
||||
)
|
||||
);
|
||||
).returning({ id: canvasImageTask.id });
|
||||
if (!requeued) return false;
|
||||
} else if (task.status !== "pending" && task.status !== "running") {
|
||||
return false;
|
||||
}
|
||||
@ -695,13 +752,32 @@ export async function ensureCanvasImageTaskActive(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function resumeDetailReplacementBatchTasks() {
|
||||
const staleBefore = new Date(Date.now() - 90_000);
|
||||
const tasks = await db.select({
|
||||
id: canvasImageTask.id,
|
||||
userId: canvasImageTask.userId,
|
||||
}).from(canvasImageTask).where(and(
|
||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
||||
or(
|
||||
eq(canvasImageTask.status, "pending"),
|
||||
and(eq(canvasImageTask.status, "running"), lt(canvasImageTask.updatedAt, staleBefore)),
|
||||
),
|
||||
)).orderBy(asc(canvasImageTask.createdAt)).limit(20);
|
||||
for (const task of tasks) {
|
||||
await ensureCanvasImageTaskActive({ taskId: task.id, userId: task.userId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCanvasImageTask(params: {
|
||||
userId: string;
|
||||
request: Record<string, unknown>;
|
||||
taskId?: string;
|
||||
}) {
|
||||
const [task] = await db
|
||||
const [created] = await db
|
||||
.insert(canvasImageTask)
|
||||
.values({
|
||||
...(params.taskId ? { id: params.taskId } : {}),
|
||||
userId: params.userId,
|
||||
status: "pending",
|
||||
request: params.request as unknown as Record<string, unknown>,
|
||||
@ -709,13 +785,25 @@ export async function createCanvasImageTask(params: {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing({ target: canvasImageTask.id })
|
||||
.returning({
|
||||
id: canvasImageTask.id,
|
||||
status: canvasImageTask.status,
|
||||
createdAt: canvasImageTask.createdAt,
|
||||
});
|
||||
|
||||
return task;
|
||||
if (created) return created;
|
||||
if (!params.taskId) throw new Error("无法创建图片生成任务");
|
||||
const [existing] = await db.select({
|
||||
id: canvasImageTask.id,
|
||||
status: canvasImageTask.status,
|
||||
createdAt: canvasImageTask.createdAt,
|
||||
}).from(canvasImageTask).where(and(
|
||||
eq(canvasImageTask.id, params.taskId),
|
||||
eq(canvasImageTask.userId, params.userId),
|
||||
)).limit(1);
|
||||
if (!existing) throw new Error("任务编号已被使用");
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function estimateModelQueueWaitMs(params: {
|
||||
@ -869,7 +957,10 @@ export async function getCanvasImageTaskForUser(params: {
|
||||
const locallyRunning = getQueueState().running.has(task.id);
|
||||
const shouldMarkStale =
|
||||
runningAgeMs >= runningTimeoutMs &&
|
||||
(!locallyRunning || runningAgeMs >= LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
|
||||
(!locallyRunning ||
|
||||
runningAgeMs >= (isDetailReplacementBatchRequest(task.request)
|
||||
? runningTimeoutMs
|
||||
: LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS));
|
||||
|
||||
if (shouldMarkStale) {
|
||||
const staleBefore = new Date(now - runningTimeoutMs);
|
||||
|
||||
5
lib/db/migrations/0101_fix_detail_replacement_model.sql
Normal file
5
lib/db/migrations/0101_fix_detail_replacement_model.sql
Normal file
@ -0,0 +1,5 @@
|
||||
UPDATE "SystemSettings"
|
||||
SET "value" = jsonb_set("value", '{modelId}', '"gpt-image-2.5"'::jsonb),
|
||||
"updatedAt" = now()
|
||||
WHERE "key" = 'detail_replacement_image_model_config'
|
||||
AND "value"->>'modelId' = 'gpt-image-2.5-flare';
|
||||
@ -0,0 +1,9 @@
|
||||
-- Keep one in-flight "详情替换" batch per identical submission. A double click or a
|
||||
-- second browser tab can no longer queue (and bill) the same images twice, while a
|
||||
-- different card selection - for example re-running one finished card - still starts
|
||||
-- its own batch.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "CanvasImageTask_detail_batch_submission_key"
|
||||
ON "CanvasImageTask" ("userId", (request ->> 'submissionKey'))
|
||||
WHERE request ->> 'taskKind' = 'agent-detail-replacement-batch'
|
||||
AND request ->> 'submissionKey' IS NOT NULL
|
||||
AND status IN ('pending', 'running');
|
||||
@ -610,6 +610,20 @@
|
||||
"when": 1788998400000,
|
||||
"tag": "0100_add_gpt_image25_detail_replacement",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 85,
|
||||
"version": "7",
|
||||
"when": 1790242039000,
|
||||
"tag": "0101_fix_detail_replacement_model",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 86,
|
||||
"version": "7",
|
||||
"when": 1790328439000,
|
||||
"tag": "0102_dedupe_detail_replacement_batches",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ export default defineConfig({
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/replacement-layout-lock\.test\.ts/,
|
||||
/routes\/replacement-product-identity\.test\.ts/,
|
||||
/routes\/detail-replacement-batch\.test\.ts/,
|
||||
/routes\/alibaba-image-input\.test\.ts/,
|
||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||
|
||||
71
tests/routes/detail-replacement-batch.test.ts
Normal file
71
tests/routes/detail-replacement-batch.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
completedDetailReplacementCardIds,
|
||||
detailReplacementCardMessageId,
|
||||
detailReplacementSubmissionKey,
|
||||
isDetailReplacementBatchRequest,
|
||||
type DetailReplacementBatchResult,
|
||||
} from "@/lib/agents/detail-replacement-batch";
|
||||
|
||||
test("recognizes only durable detail replacement batches", () => {
|
||||
expect(isDetailReplacementBatchRequest({ taskKind: "agent-detail-replacement-batch" })).toBe(true);
|
||||
expect(isDetailReplacementBatchRequest({ taskKind: "style-pro" })).toBe(false);
|
||||
expect(isDetailReplacementBatchRequest(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("card message identity stays stable across retries and page reloads", () => {
|
||||
const id = detailReplacementCardMessageId("batch-a", "card-01");
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
expect(detailReplacementCardMessageId("batch-a", "card-01")).toBe(id);
|
||||
expect(detailReplacementCardMessageId("batch-a", "card-02")).not.toBe(id);
|
||||
});
|
||||
|
||||
test("restart skips successful cards but retries failed ones", () => {
|
||||
const result: DetailReplacementBatchResult = {
|
||||
taskKind: "agent-detail-replacement-batch",
|
||||
currentCardId: null,
|
||||
cards: [
|
||||
{ cardId: "one", status: "completed", imageUrl: "https://example.com/one.png", thumbnailUrl: "https://example.com/one.png", text: "" },
|
||||
{ cardId: "two", status: "fallback", imageUrl: "https://example.com/two.png", thumbnailUrl: "https://example.com/two.png", text: "生成失败" },
|
||||
{ cardId: "three", status: "passthrough", imageUrl: "https://example.com/three.png", thumbnailUrl: "https://example.com/three.png", text: "" },
|
||||
],
|
||||
};
|
||||
expect([...completedDetailReplacementCardIds(result)]).toEqual(["one", "three"]);
|
||||
});
|
||||
|
||||
const submission = {
|
||||
projectId: "11111111-1111-1111-1111-111111111111",
|
||||
model: "gpt-image-2.5",
|
||||
prompt: "保持版式,替换产品主体",
|
||||
userPrompt: "详情替换",
|
||||
imageSize: "2K",
|
||||
replacementStrategy: "scene_replace",
|
||||
productImageUrls: ["https://example.com/p2.png", "https://example.com/p1.png"],
|
||||
modelImageUrl: null,
|
||||
references: [
|
||||
{ id: "card-01", label: "首屏", imageUrl: "https://example.com/a.png" },
|
||||
{ id: "card-02", label: "第二屏", imageUrl: "https://example.com/b.png" },
|
||||
],
|
||||
};
|
||||
|
||||
test("an identical double click resolves to the same submission key", () => {
|
||||
expect(detailReplacementSubmissionKey(submission)).toBe(
|
||||
detailReplacementSubmissionKey({
|
||||
...submission,
|
||||
productImageUrls: [...submission.productImageUrls].reverse(),
|
||||
references: [...submission.references].reverse(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("re-running a single card while a batch runs is not treated as a duplicate", () => {
|
||||
const full = detailReplacementSubmissionKey(submission);
|
||||
expect(detailReplacementSubmissionKey({
|
||||
...submission,
|
||||
references: [submission.references[0]!],
|
||||
})).not.toBe(full);
|
||||
expect(detailReplacementSubmissionKey({
|
||||
...submission,
|
||||
prompt: "换一个背景",
|
||||
})).not.toBe(full);
|
||||
});
|
||||
@ -8,7 +8,10 @@ import {
|
||||
normalizeReplacementProductImageIndexes,
|
||||
parseReplacementProductIdentityProfile,
|
||||
} from "@/lib/ai/replacement-product-identity";
|
||||
import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs";
|
||||
import {
|
||||
selectDetailReplacementGenerationInputs,
|
||||
shouldIncludeDetailReplacementModelImage,
|
||||
} from "@/lib/ai/detail-replacement-inputs";
|
||||
|
||||
test("parses a reusable multi-view product structure profile", () => {
|
||||
const profile = parseReplacementProductIdentityProfile(
|
||||
@ -69,16 +72,22 @@ test("makes product topology authoritative over the old detail product", () => {
|
||||
expect(prompt).toContain("旧商品结构必须完整移除");
|
||||
});
|
||||
|
||||
test("binds non-Pro detail replacement to GPT Image 2.5 Flare in database config", () => {
|
||||
expect(DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID).toBe("gpt-image-2.5-flare");
|
||||
test("binds non-Pro detail replacement to GPT Image 2.5 in database config", () => {
|
||||
expect(DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID).toBe("gpt-image-2.5");
|
||||
expect(normalizeDetailReplacementImageModelConfig(null)).toEqual({
|
||||
modelId: "gpt-image-2.5-flare",
|
||||
modelId: "gpt-image-2.5",
|
||||
});
|
||||
expect(
|
||||
normalizeDetailReplacementImageModelConfig({ modelId: "custom-image-model" }),
|
||||
).toEqual({ modelId: "custom-image-model" });
|
||||
});
|
||||
|
||||
test("keeps model-identity images on people-oriented detail pages", () => {
|
||||
expect(shouldIncludeDetailReplacementModelImage({ peoplePolicy: "preserve", useModelImage: false })).toBe(true);
|
||||
expect(shouldIncludeDetailReplacementModelImage({ sceneSummary: "固定模特在客厅展示产品" })).toBe(true);
|
||||
expect(shouldIncludeDetailReplacementModelImage({ pageKind: "product_detail", peoplePolicy: "none" })).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the product, required model identity, and final layout within three inputs", () => {
|
||||
const selected = selectDetailReplacementGenerationInputs({
|
||||
productImageUrls: ["https://assets.test/product-1.png", "https://assets.test/product-2.png"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user