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,
|
resolveReplacementReferenceGeometry,
|
||||||
resolveReplacementScenePlanningRuntime,
|
resolveReplacementScenePlanningRuntime,
|
||||||
} from "./_lib/detail-replacement";
|
} from "./_lib/detail-replacement";
|
||||||
import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs";
|
import {
|
||||||
|
selectDetailReplacementGenerationInputs,
|
||||||
|
shouldIncludeDetailReplacementModelImage,
|
||||||
|
} from "@/lib/ai/detail-replacement-inputs";
|
||||||
import {
|
import {
|
||||||
normalizeAgentReportForDisplay,
|
normalizeAgentReportForDisplay,
|
||||||
sanitizeAgentOutputSettingsSection,
|
sanitizeAgentOutputSettingsSection,
|
||||||
@ -419,8 +422,6 @@ const chatApiLogger = createLogger("chat.route");
|
|||||||
|
|
||||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||||
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
|
||||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
|
||||||
|
|
||||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||||
const normalizedMarkdown = String(markdown || "").trim();
|
const normalizedMarkdown = String(markdown || "").trim();
|
||||||
@ -443,35 +444,6 @@ function getPublicImageGenerationErrorText(error: unknown) {
|
|||||||
return message;
|
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(
|
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
|
||||||
1,
|
1,
|
||||||
@ -5940,7 +5912,7 @@ export async function POST(request: Request) {
|
|||||||
const shouldIncludeModelImage =
|
const shouldIncludeModelImage =
|
||||||
detailReplacementStrategy !== "product_only" &&
|
detailReplacementStrategy !== "product_only" &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(replacementScenePlanShouldIncludeModelImage(
|
(shouldIncludeDetailReplacementModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
selectedProductContextImageUrls.length === 0);
|
selectedProductContextImageUrls.length === 0);
|
||||||
@ -6833,7 +6805,7 @@ export async function POST(request: Request) {
|
|||||||
!(isDetailReplacementPreset &&
|
!(isDetailReplacementPreset &&
|
||||||
detailReplacementStrategy === "product_only") &&
|
detailReplacementStrategy === "product_only") &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(replacementScenePlanShouldIncludeModelImage(
|
(shouldIncludeDetailReplacementModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
(isDetailReplacementPreset &&
|
(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 { useChat } from "@ai-sdk/react";
|
||||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
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 { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@ -2564,10 +2565,23 @@ interface LocalGenerationPendingState {
|
|||||||
expectedCount: number;
|
expectedCount: number;
|
||||||
userTurnCountAtStart: number;
|
userTurnCountAtStart: number;
|
||||||
requestMessageId?: string | null;
|
requestMessageId?: string | null;
|
||||||
|
backendTaskId?: string | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
requestedCardIds?: string[] | null;
|
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_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
||||||
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
||||||
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
||||||
@ -2619,6 +2633,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
|||||||
expectedCount: Number(record.expectedCount),
|
expectedCount: Number(record.expectedCount),
|
||||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||||
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||||
|
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
|
||||||
timestamp: Number(record.timestamp),
|
timestamp: Number(record.timestamp),
|
||||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||||
? record.requestedCardIds
|
? record.requestedCardIds
|
||||||
@ -2651,16 +2666,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETAIL_RESULT_PROCESSING_STAGES = [
|
|
||||||
"解码图像",
|
|
||||||
"定位选区",
|
|
||||||
"分离主体",
|
|
||||||
"重绘背景",
|
|
||||||
"融合边缘",
|
|
||||||
"检查细节",
|
|
||||||
"输出结果",
|
|
||||||
];
|
|
||||||
|
|
||||||
function DetailResultProcessingScanOverlay({
|
function DetailResultProcessingScanOverlay({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
label,
|
label,
|
||||||
@ -2668,29 +2673,6 @@ function DetailResultProcessingScanOverlay({
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
label: 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 (
|
return (
|
||||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||||
<style>{`
|
<style>{`
|
||||||
@ -2804,26 +2786,6 @@ function DetailResultProcessingScanOverlay({
|
|||||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -3166,6 +3128,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
||||||
const detailGenerationCancelRef = useRef(false);
|
const detailGenerationCancelRef = useRef(false);
|
||||||
const workspaceStateSignatureRef = useRef<string>("");
|
const workspaceStateSignatureRef = useRef<string>("");
|
||||||
|
const detailBatchSubmittingRef = useRef(false);
|
||||||
|
const reportedDetailBatchFailureRef = useRef<string | null>(null);
|
||||||
const generationRequestSnapshotRef = useRef<{
|
const generationRequestSnapshotRef = useRef<{
|
||||||
hiddenPrompt: string | null;
|
hiddenPrompt: string | null;
|
||||||
replacementExecutionCardIds: string[] | null;
|
replacementExecutionCardIds: string[] | null;
|
||||||
@ -3500,6 +3464,75 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||||
const isReplacementPreset =
|
const isReplacementPreset =
|
||||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
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 =
|
const isProductOnlyDetailReplacementMode =
|
||||||
currentResultModeType === "detail_replacement" &&
|
currentResultModeType === "detail_replacement" &&
|
||||||
detailReplacementStrategy === "product_only";
|
detailReplacementStrategy === "product_only";
|
||||||
@ -4996,7 +5029,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
Array.isArray(pendingGeneration.requestedCardIds)
|
Array.isArray(pendingGeneration.requestedCardIds)
|
||||||
? pendingGeneration.requestedCardIds.length
|
? pendingGeneration.requestedCardIds.length
|
||||||
: null,
|
: 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,
|
index,
|
||||||
resultUrl,
|
resultUrl,
|
||||||
resultText,
|
resultText,
|
||||||
@ -5010,6 +5053,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
|
detailBatchTask,
|
||||||
isDetailResultModeGenerating,
|
isDetailResultModeGenerating,
|
||||||
replacementRunRecovery.completedCardIds,
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
@ -5164,7 +5208,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const detailRemainingCount = useMemo(
|
const detailRemainingCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detailResultCards.filter(
|
detailResultCards.filter(
|
||||||
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
|
(card) => !["completed", "passthrough"].includes(card.status)
|
||||||
).length,
|
).length,
|
||||||
[detailResultCards]
|
[detailResultCards]
|
||||||
);
|
);
|
||||||
@ -5439,7 +5483,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
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.
|
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||||
stop();
|
stop();
|
||||||
transportGenerationStartedAtRef.current = null;
|
transportGenerationStartedAtRef.current = null;
|
||||||
@ -5447,7 +5491,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5457,7 +5501,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || !chatError) {
|
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5472,7 +5516,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [chatError, clearError, pendingGeneration]);
|
}, [chatError, clearError, pendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration) {
|
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6198,7 +6242,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const processedStatuses = new Set<ReplacementCardStatus>([
|
const processedStatuses = new Set<ReplacementCardStatus>([
|
||||||
"completed",
|
"completed",
|
||||||
"passthrough",
|
"passthrough",
|
||||||
"fallback",
|
|
||||||
]);
|
]);
|
||||||
const remainingIds = resultCards
|
const remainingIds = resultCards
|
||||||
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
||||||
@ -6304,6 +6347,82 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
||||||
return;
|
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 parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
||||||
const sanitizedAttachments = sceneMessageAttachments;
|
const sanitizedAttachments = sceneMessageAttachments;
|
||||||
if (
|
if (
|
||||||
@ -6453,6 +6572,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
detailResultCards,
|
detailResultCards,
|
||||||
detailReplacementReferenceCards.length,
|
detailReplacementReferenceCards.length,
|
||||||
detailReplacementStrategy,
|
detailReplacementStrategy,
|
||||||
|
detailReplacementProductSourceImages,
|
||||||
|
detailReplacementModelSourceImage,
|
||||||
handleRunAiDetailGeneration,
|
handleRunAiDetailGeneration,
|
||||||
currentResultModeType,
|
currentResultModeType,
|
||||||
getReplacementExecutionCards,
|
getReplacementExecutionCards,
|
||||||
@ -6483,6 +6604,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
scenePrimaryReferenceCount,
|
scenePrimaryReferenceCount,
|
||||||
sceneSecondaryReferenceCount,
|
sceneSecondaryReferenceCount,
|
||||||
selectedModelAttachment,
|
selectedModelAttachment,
|
||||||
|
selectedProductAttachments,
|
||||||
|
onPersistProject,
|
||||||
|
projectId,
|
||||||
|
refreshDetailBatch,
|
||||||
]);
|
]);
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
detailGenerationCancelRef.current = true;
|
detailGenerationCancelRef.current = true;
|
||||||
@ -9339,7 +9464,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{isAnyGenerationRunning ? (
|
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -2,4 +2,17 @@ import { registerOTel } from "@vercel/otel";
|
|||||||
|
|
||||||
export function register() {
|
export function register() {
|
||||||
registerOTel({ serviceName: "ai-chatbot" });
|
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 { and, eq } from "drizzle-orm";
|
||||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
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 { db } from "@/lib/db";
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
import type { AgentGenerationResultModeType } from "@/types/agent";
|
import type {
|
||||||
|
AgentGenerationReplacementResultRecord,
|
||||||
|
AgentGenerationResultModeType,
|
||||||
|
} from "@/types/agent";
|
||||||
|
|
||||||
const UUID_PATTERN =
|
const UUID_PATTERN =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
/^[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);
|
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 =
|
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
|
||||||
"detail_replacement_image_model_config";
|
"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 = {
|
export type DetailReplacementImageModelConfig = {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
|||||||
@ -4,6 +4,42 @@ export type DetailReplacementGenerationInputRole =
|
|||||||
| "supplement"
|
| "supplement"
|
||||||
| "layout";
|
| "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: {
|
export function selectDetailReplacementGenerationInputs(params: {
|
||||||
productImageUrls: string[];
|
productImageUrls: string[];
|
||||||
modelImageUrl?: string | null;
|
modelImageUrl?: string | null;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import "server-only";
|
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 { db } from "@/lib/db";
|
||||||
import { canvasImageTask } from "@/lib/db/schema";
|
import { canvasImageTask } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
@ -31,6 +31,12 @@ import {
|
|||||||
markCanvasLayoutReplaceBatchItemRunning,
|
markCanvasLayoutReplaceBatchItemRunning,
|
||||||
} from "@/lib/canvas/layout-replace-batch";
|
} from "@/lib/canvas/layout-replace-batch";
|
||||||
import { persistAgentProjectGeneratedResult } from "@/lib/agents/agent-project-result-persistence";
|
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 =
|
type CanvasImageTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
@ -41,8 +47,9 @@ type CanvasImageTaskStatus =
|
|||||||
type CanvasImageTaskRequest =
|
type CanvasImageTaskRequest =
|
||||||
| CanvasGenerateImageRequest
|
| CanvasGenerateImageRequest
|
||||||
| StyleProTaskRequest
|
| StyleProTaskRequest
|
||||||
| CanvasLayoutReplaceTaskRequest;
|
| CanvasLayoutReplaceTaskRequest
|
||||||
type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult;
|
| DetailReplacementBatchRequest;
|
||||||
|
type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult | DetailReplacementBatchResult;
|
||||||
|
|
||||||
export type CanvasImageTaskQueueStatus = {
|
export type CanvasImageTaskQueueStatus = {
|
||||||
status: CanvasImageTaskStatus;
|
status: CanvasImageTaskStatus;
|
||||||
@ -129,6 +136,7 @@ function getRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCanvasImageRunningTimeoutMs(request: unknown) {
|
function getCanvasImageRunningTimeoutMs(request: unknown) {
|
||||||
|
if (isDetailReplacementBatchRequest(request)) return 3 * 60 * 60 * 1000;
|
||||||
const record = getRecord(request);
|
const record = getRecord(request);
|
||||||
const debugMeta = getRecord(record?.debugMeta);
|
const debugMeta = getRecord(record?.debugMeta);
|
||||||
const debugLabel = String(record?.debugLabel || "");
|
const debugLabel = String(record?.debugLabel || "");
|
||||||
@ -425,16 +433,19 @@ async function markStaleDatabaseRunningTasksFailed() {
|
|||||||
|
|
||||||
const localRunningIds = Array.from(state.running.keys());
|
const localRunningIds = Array.from(state.running.keys());
|
||||||
const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
|
const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
|
||||||
|
const nonDetailBatch = sql`coalesce(${canvasImageTask.request}->>'taskKind', '') <> 'agent-detail-replacement-batch'`;
|
||||||
const staleWhere =
|
const staleWhere =
|
||||||
localRunningIds.length > 0
|
localRunningIds.length > 0
|
||||||
? and(
|
? and(
|
||||||
eq(canvasImageTask.status, "running"),
|
eq(canvasImageTask.status, "running"),
|
||||||
lt(canvasImageTask.startedAt, staleBefore),
|
lt(canvasImageTask.startedAt, staleBefore),
|
||||||
|
nonDetailBatch,
|
||||||
notInArray(canvasImageTask.id, localRunningIds)
|
notInArray(canvasImageTask.id, localRunningIds)
|
||||||
)
|
)
|
||||||
: and(
|
: and(
|
||||||
eq(canvasImageTask.status, "running"),
|
eq(canvasImageTask.status, "running"),
|
||||||
lt(canvasImageTask.startedAt, staleBefore)
|
lt(canvasImageTask.startedAt, staleBefore),
|
||||||
|
nonDetailBatch
|
||||||
);
|
);
|
||||||
|
|
||||||
const completedAt = new Date(now);
|
const completedAt = new Date(now);
|
||||||
@ -502,6 +513,12 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
request,
|
request,
|
||||||
userId: String(task.userId || entry.userId),
|
userId: String(task.userId || entry.userId),
|
||||||
})
|
})
|
||||||
|
: isDetailReplacementBatchRequest(request)
|
||||||
|
? executeDetailReplacementBatch({
|
||||||
|
taskId: task.id,
|
||||||
|
userId: String(task.userId || entry.userId),
|
||||||
|
request,
|
||||||
|
})
|
||||||
: isCanvasLayoutReplaceTaskRequest(request)
|
: isCanvasLayoutReplaceTaskRequest(request)
|
||||||
? executeCanvasLayoutReplaceTask({
|
? executeCanvasLayoutReplaceTask({
|
||||||
request,
|
request,
|
||||||
@ -521,11 +538,25 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
request: imageRequest,
|
request: imageRequest,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
const result = await withCanvasImageTaskExecutionTimeout(
|
const heartbeat = isDetailReplacementBatchRequest(request)
|
||||||
execution,
|
? setInterval(() => {
|
||||||
runningTimeoutMs,
|
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
||||||
entry.taskId
|
.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;
|
completedBatchResult = result;
|
||||||
if (isCanvasLayoutReplaceTaskRequest(request) && request.agentProjectId) {
|
if (isCanvasLayoutReplaceTaskRequest(request) && request.agentProjectId) {
|
||||||
const layoutReplaceResult = result as CanvasGenerateImageResult;
|
const layoutReplaceResult = result as CanvasGenerateImageResult;
|
||||||
@ -641,6 +672,8 @@ export async function ensureCanvasImageTaskActive(params: {
|
|||||||
request: canvasImageTask.request,
|
request: canvasImageTask.request,
|
||||||
result: canvasImageTask.result,
|
result: canvasImageTask.result,
|
||||||
error: canvasImageTask.error,
|
error: canvasImageTask.error,
|
||||||
|
attempts: canvasImageTask.attempts,
|
||||||
|
updatedAt: canvasImageTask.updatedAt,
|
||||||
})
|
})
|
||||||
.from(canvasImageTask)
|
.from(canvasImageTask)
|
||||||
.where(
|
.where(
|
||||||
@ -653,6 +686,22 @@ export async function ensureCanvasImageTaskActive(params: {
|
|||||||
if (!task || task.status === "waiting") return false;
|
if (!task || task.status === "waiting") return false;
|
||||||
|
|
||||||
const request = task.request as CanvasImageTaskRequest;
|
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 (
|
if (
|
||||||
(task.status === "succeeded" || task.status === "failed") &&
|
(task.status === "succeeded" || task.status === "failed") &&
|
||||||
isCanvasLayoutReplaceTaskRequest(request) &&
|
isCanvasLayoutReplaceTaskRequest(request) &&
|
||||||
@ -671,7 +720,11 @@ export async function ensureCanvasImageTaskActive(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (task.status === "running" && !getQueueState().running.has(task.id)) {
|
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)
|
.update(canvasImageTask)
|
||||||
.set({
|
.set({
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@ -683,9 +736,13 @@ export async function ensureCanvasImageTaskActive(params: {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(canvasImageTask.id, task.id),
|
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") {
|
} else if (task.status !== "pending" && task.status !== "running") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -695,13 +752,32 @@ export async function ensureCanvasImageTaskActive(params: {
|
|||||||
return true;
|
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: {
|
export async function createCanvasImageTask(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
request: Record<string, unknown>;
|
request: Record<string, unknown>;
|
||||||
|
taskId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [task] = await db
|
const [created] = await db
|
||||||
.insert(canvasImageTask)
|
.insert(canvasImageTask)
|
||||||
.values({
|
.values({
|
||||||
|
...(params.taskId ? { id: params.taskId } : {}),
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
request: params.request as unknown as Record<string, unknown>,
|
request: params.request as unknown as Record<string, unknown>,
|
||||||
@ -709,13 +785,25 @@ export async function createCanvasImageTask(params: {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
.onConflictDoNothing({ target: canvasImageTask.id })
|
||||||
.returning({
|
.returning({
|
||||||
id: canvasImageTask.id,
|
id: canvasImageTask.id,
|
||||||
status: canvasImageTask.status,
|
status: canvasImageTask.status,
|
||||||
createdAt: canvasImageTask.createdAt,
|
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: {
|
async function estimateModelQueueWaitMs(params: {
|
||||||
@ -869,7 +957,10 @@ export async function getCanvasImageTaskForUser(params: {
|
|||||||
const locallyRunning = getQueueState().running.has(task.id);
|
const locallyRunning = getQueueState().running.has(task.id);
|
||||||
const shouldMarkStale =
|
const shouldMarkStale =
|
||||||
runningAgeMs >= runningTimeoutMs &&
|
runningAgeMs >= runningTimeoutMs &&
|
||||||
(!locallyRunning || runningAgeMs >= LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
|
(!locallyRunning ||
|
||||||
|
runningAgeMs >= (isDetailReplacementBatchRequest(task.request)
|
||||||
|
? runningTimeoutMs
|
||||||
|
: LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS));
|
||||||
|
|
||||||
if (shouldMarkStale) {
|
if (shouldMarkStale) {
|
||||||
const staleBefore = new Date(now - runningTimeoutMs);
|
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,
|
"when": 1788998400000,
|
||||||
"tag": "0100_add_gpt_image25_detail_replacement",
|
"tag": "0100_add_gpt_image25_detail_replacement",
|
||||||
"breakpoints": true
|
"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\/model-classification\.test\.ts/,
|
||||||
/routes\/replacement-layout-lock\.test\.ts/,
|
/routes\/replacement-layout-lock\.test\.ts/,
|
||||||
/routes\/replacement-product-identity\.test\.ts/,
|
/routes\/replacement-product-identity\.test\.ts/,
|
||||||
|
/routes\/detail-replacement-batch\.test\.ts/,
|
||||||
/routes\/alibaba-image-input\.test\.ts/,
|
/routes\/alibaba-image-input\.test\.ts/,
|
||||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.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,
|
normalizeReplacementProductImageIndexes,
|
||||||
parseReplacementProductIdentityProfile,
|
parseReplacementProductIdentityProfile,
|
||||||
} from "@/lib/ai/replacement-product-identity";
|
} 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", () => {
|
test("parses a reusable multi-view product structure profile", () => {
|
||||||
const profile = parseReplacementProductIdentityProfile(
|
const profile = parseReplacementProductIdentityProfile(
|
||||||
@ -69,16 +72,22 @@ test("makes product topology authoritative over the old detail product", () => {
|
|||||||
expect(prompt).toContain("旧商品结构必须完整移除");
|
expect(prompt).toContain("旧商品结构必须完整移除");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("binds non-Pro detail replacement to GPT Image 2.5 Flare in database config", () => {
|
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-flare");
|
expect(DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID).toBe("gpt-image-2.5");
|
||||||
expect(normalizeDetailReplacementImageModelConfig(null)).toEqual({
|
expect(normalizeDetailReplacementImageModelConfig(null)).toEqual({
|
||||||
modelId: "gpt-image-2.5-flare",
|
modelId: "gpt-image-2.5",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
normalizeDetailReplacementImageModelConfig({ modelId: "custom-image-model" }),
|
normalizeDetailReplacementImageModelConfig({ modelId: "custom-image-model" }),
|
||||||
).toEqual({ 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", () => {
|
test("keeps the product, required model identity, and final layout within three inputs", () => {
|
||||||
const selected = selectDetailReplacementGenerationInputs({
|
const selected = selectDetailReplacementGenerationInputs({
|
||||||
productImageUrls: ["https://assets.test/product-1.png", "https://assets.test/product-2.png"],
|
productImageUrls: ["https://assets.test/product-1.png", "https://assets.test/product-2.png"],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user