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:
134 2026-09-26 02:39:11 +08:00
parent 1c2acee547
commit 0907aaff67
16 changed files with 1235 additions and 117 deletions

View File

@ -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 &&

View 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 });
}
}

View File

@ -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"

View File

@ -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?.();
}

View File

@ -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;
});
}

View 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);
}

View 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("-");
}

View File

@ -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;

View File

@ -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;

View File

@ -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);

View 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';

View File

@ -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');

View File

@ -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
}
]
}

View File

@ -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/,

View 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);
});

View File

@ -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"],