fix(agent): sync detail replacement task status
This commit is contained in:
parent
c988f2dee0
commit
c5c49b52a9
@ -4,6 +4,7 @@ import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
getCanvasImageTaskForUser,
|
||||
getCanvasDetailReplacementTasksForUserContext,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
generateCanvasImage,
|
||||
@ -87,6 +88,19 @@ export async function GET(request: Request) {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||
const chatId = String(searchParams.get("chatId") || "").trim();
|
||||
const projectId = String(searchParams.get("projectId") || "").trim();
|
||||
if (!taskId && chatId) {
|
||||
const tasks = await getCanvasDetailReplacementTasksForUserContext({
|
||||
userId: session.user.id,
|
||||
chatId,
|
||||
projectId: projectId || null,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 taskId 参数" },
|
||||
|
||||
@ -118,6 +118,14 @@ import {
|
||||
isReplacementResultMode,
|
||||
shouldShowAgentGridResultSection,
|
||||
} from "@/lib/agents/generation-result-visibility";
|
||||
import {
|
||||
buildLatestAgentReplacementTaskMap,
|
||||
getAgentReplacementTaskResultImageUrl,
|
||||
isAgentReplacementTaskInPendingRun,
|
||||
resolveAgentReplacementCardStatus,
|
||||
type AgentReplacementCardStatus,
|
||||
type AgentReplacementTaskSnapshot,
|
||||
} from "@/lib/agents/replacement-task-status";
|
||||
import {
|
||||
defaultAgentWorkflowBusinessModeSettings,
|
||||
normalizeAgentWorkflowBusinessModeSettings,
|
||||
@ -2401,14 +2409,12 @@ const IMAGE_OVERLAY_DANGER_TEXT_CLASS =
|
||||
const IMAGE_OVERLAY_DANGER_ICON_CLASS =
|
||||
"absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-rose-200/55 bg-zinc-950/82 text-rose-50 shadow-[0_12px_34px_rgba(0,0,0,0.50)] backdrop-blur-md transition-colors hover:border-rose-100 hover:bg-rose-500/35";
|
||||
|
||||
type ReplacementCardStatus =
|
||||
| "waiting"
|
||||
| "queued"
|
||||
| "generating"
|
||||
| "completed"
|
||||
| "passthrough"
|
||||
| "fallback"
|
||||
| "missing";
|
||||
type ReplacementCardStatus = AgentReplacementCardStatus;
|
||||
|
||||
interface AgentReplacementTasksResponse {
|
||||
success?: boolean;
|
||||
tasks?: AgentReplacementTaskSnapshot[];
|
||||
}
|
||||
|
||||
type ReplacementModeType = Extract<
|
||||
AgentGenerationResultModeType,
|
||||
@ -2482,7 +2488,9 @@ function areReplacementResultStatesEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
||||
function inferReplacementCardStatusFromText(
|
||||
text: string | null | undefined
|
||||
): "completed" | "passthrough" | "fallback" | null {
|
||||
const normalized = normalizeAssistantText(String(text || ""))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
@ -2511,30 +2519,19 @@ function getReplacementCardStatus(params: {
|
||||
generatedCount: number;
|
||||
isGenerating: boolean;
|
||||
activeRunIndex?: number | null;
|
||||
taskStatus?: AgentReplacementTaskSnapshot["status"] | null;
|
||||
isRequestedInPendingRun?: boolean;
|
||||
}): ReplacementCardStatus {
|
||||
if (
|
||||
typeof params.activeRunIndex === "number" &&
|
||||
params.activeRunIndex >= 0 &&
|
||||
params.generatedCount <= params.activeRunIndex
|
||||
) {
|
||||
if (params.isGenerating) {
|
||||
return params.generatedCount === params.activeRunIndex ? "generating" : "queued";
|
||||
}
|
||||
if (!params.resultUrl && params.generatedCount > 0) {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
if (params.resultUrl) {
|
||||
return inferReplacementCardStatusFromText(params.resultText) || "completed";
|
||||
}
|
||||
if (params.isGenerating) {
|
||||
return params.index === params.generatedCount ? "generating" : "queued";
|
||||
}
|
||||
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
|
||||
return "missing";
|
||||
}
|
||||
return "waiting";
|
||||
return resolveAgentReplacementCardStatus({
|
||||
index: params.index,
|
||||
resultUrl: params.resultUrl,
|
||||
persistedResultStatus: inferReplacementCardStatusFromText(params.resultText),
|
||||
generatedCount: params.generatedCount,
|
||||
isGenerating: params.isGenerating,
|
||||
activeRunIndex: params.activeRunIndex,
|
||||
taskStatus: params.taskStatus,
|
||||
isRequestedInPendingRun: params.isRequestedInPendingRun === true,
|
||||
});
|
||||
}
|
||||
|
||||
function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
|
||||
@ -2550,6 +2547,11 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
|
||||
label: "失败已沿用",
|
||||
className: "border-amber-100/80 bg-amber-500/92 text-zinc-950",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
label: "生成失败",
|
||||
className: "border-rose-100/80 bg-rose-600/92 text-white",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
label: "已生成",
|
||||
@ -3472,6 +3474,54 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||
const isReplacementPreset =
|
||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
||||
const replacementTasksUrl =
|
||||
currentResultModeType === "detail_replacement" && rawChatId
|
||||
? `/api/canvas/generate/image?chatId=${encodeURIComponent(
|
||||
rawChatId
|
||||
)}${
|
||||
isValidChatUuid(projectId)
|
||||
? `&projectId=${encodeURIComponent(projectId)}`
|
||||
: ""
|
||||
}`
|
||||
: null;
|
||||
const {
|
||||
data: replacementTasksData,
|
||||
mutate: refreshReplacementTasks,
|
||||
} = useSWR<AgentReplacementTasksResponse>(replacementTasksUrl, fetcher, {
|
||||
refreshInterval: (latestData) =>
|
||||
pendingGeneration ||
|
||||
latestData?.tasks?.some(
|
||||
(task) => task.status === "pending" || task.status === "running"
|
||||
)
|
||||
? 2500
|
||||
: 0,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
const replacementTaskSnapshots = useMemo(
|
||||
() =>
|
||||
Array.isArray(replacementTasksData?.tasks)
|
||||
? replacementTasksData.tasks.filter(
|
||||
(task) => task.modeType === "detail_replacement"
|
||||
)
|
||||
: [],
|
||||
[replacementTasksData?.tasks]
|
||||
);
|
||||
const latestDetailTaskByCardId = useMemo(
|
||||
() =>
|
||||
buildLatestAgentReplacementTaskMap({
|
||||
tasks: replacementTaskSnapshots,
|
||||
modeType: "detail_replacement",
|
||||
}),
|
||||
[replacementTaskSnapshots]
|
||||
);
|
||||
const hasActiveReplacementTasks = replacementTaskSnapshots.some(
|
||||
(task) => task.status === "pending" || task.status === "running"
|
||||
);
|
||||
const hasActiveDetailReplacementTasks = replacementTaskSnapshots.some(
|
||||
(task) =>
|
||||
task.modeType === "detail_replacement" &&
|
||||
(task.status === "pending" || task.status === "running")
|
||||
);
|
||||
const isProductOnlyDetailReplacementMode =
|
||||
currentResultModeType === "detail_replacement" &&
|
||||
detailReplacementStrategy === "product_only";
|
||||
@ -4703,6 +4753,74 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
latestReplacementResults,
|
||||
skuReplacementReferenceCards,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const succeededTasks = replacementTaskSnapshots.filter(
|
||||
(task) =>
|
||||
task.modeType === "detail_replacement" &&
|
||||
task.status === "succeeded" &&
|
||||
Boolean(getAgentReplacementTaskResultImageUrl(task.result))
|
||||
);
|
||||
if (succeededTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const referenceCardById = new Map(
|
||||
detailReplacementReferenceCards.map((card) => [card.id, card])
|
||||
);
|
||||
setReplacementResultState((current) => {
|
||||
const nextDetailState = { ...current.detail_replacement };
|
||||
let changed = false;
|
||||
|
||||
succeededTasks.forEach((task) => {
|
||||
const referenceCard = referenceCardById.get(task.referenceId);
|
||||
const resultUrl = getAgentReplacementTaskResultImageUrl(task.result);
|
||||
if (!referenceCard || !resultUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskUpdatedAt = Date.parse(
|
||||
task.completedAt || task.startedAt || task.createdAt
|
||||
);
|
||||
const previousRecord = nextDetailState[task.referenceId];
|
||||
if (
|
||||
previousRecord?.url === resultUrl ||
|
||||
(Number.isFinite(taskUpdatedAt) &&
|
||||
Number(previousRecord?.updatedAt || 0) > taskUpdatedAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextDetailState[task.referenceId] = {
|
||||
url: resultUrl,
|
||||
text: "已从后台详情替换任务恢复生图结果。",
|
||||
status: "completed",
|
||||
updatedAt: Number.isFinite(taskUpdatedAt) ? taskUpdatedAt : Date.now(),
|
||||
compareSourceUrl: normalizeResultCompareSourceUrl(
|
||||
resultUrl,
|
||||
previousRecord?.url ||
|
||||
referenceCard.imageUrl ||
|
||||
referenceCard.previewUrl ||
|
||||
previousRecord?.compareSourceUrl ||
|
||||
null
|
||||
),
|
||||
referenceImageUrl:
|
||||
referenceCard.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||
referencePreviewUrl:
|
||||
referenceCard.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||
label: referenceCard.label || previousRecord?.label || null,
|
||||
};
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
detail_replacement: nextDetailState,
|
||||
};
|
||||
});
|
||||
}, [detailReplacementReferenceCards, replacementTaskSnapshots]);
|
||||
const detailReplacementResultUrls = useMemo(
|
||||
() => {
|
||||
const urlsFromCards = detailReplacementReferenceCards
|
||||
@ -4787,6 +4905,32 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pendingGeneration.modeType === "detail_replacement") {
|
||||
const completedCardIds = new Set<string>();
|
||||
latestReplacementResults.forEach((result, index) => {
|
||||
const cardId =
|
||||
result.replacementCardId || pendingGeneration.requestedCardIds?.[index] || null;
|
||||
if (cardId) {
|
||||
completedCardIds.add(cardId);
|
||||
}
|
||||
});
|
||||
(pendingGeneration.requestedCardIds || []).forEach((cardId) => {
|
||||
const task = latestDetailTaskByCardId.get(cardId);
|
||||
if (
|
||||
task &&
|
||||
(task.status === "succeeded" || task.status === "failed") &&
|
||||
isAgentReplacementTaskInPendingRun({
|
||||
task,
|
||||
pendingStartedAt: pendingGeneration.timestamp,
|
||||
requestMessageId: pendingGeneration.requestMessageId,
|
||||
})
|
||||
) {
|
||||
completedCardIds.add(cardId);
|
||||
}
|
||||
});
|
||||
return completedCardIds.size;
|
||||
}
|
||||
|
||||
if (
|
||||
pendingGeneration.modeType !== "detail_generation" &&
|
||||
userMessageCount <= pendingGeneration.userTurnCountAtStart
|
||||
@ -4806,14 +4950,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [
|
||||
currentSceneGeneratedUrls.length,
|
||||
detailGenerationRunResults.length,
|
||||
latestReplacementResults.length,
|
||||
latestDetailTaskByCardId,
|
||||
latestReplacementResults,
|
||||
pendingGeneration,
|
||||
userMessageCount,
|
||||
]);
|
||||
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
|
||||
const isAnyGenerationRunning =
|
||||
transportIsGenerating || Boolean(pendingGeneration) || hasActiveReplacementTasks;
|
||||
const isDetailResultModeGenerating =
|
||||
pendingGeneration?.modeType === "detail_replacement" ||
|
||||
(currentResultModeType === "detail_replacement" && transportIsGenerating);
|
||||
(currentResultModeType === "detail_replacement" &&
|
||||
(transportIsGenerating || hasActiveDetailReplacementTasks));
|
||||
const isSkuResultModeGenerating =
|
||||
pendingGeneration?.modeType === "sku_replacement" ||
|
||||
(currentResultModeType === "sku_replacement" && transportIsGenerating);
|
||||
@ -4915,12 +5062,25 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
Array.isArray(pendingGeneration.requestedCardIds)
|
||||
? pendingGeneration.requestedCardIds.indexOf(card.id)
|
||||
: -1;
|
||||
const latestTask = latestDetailTaskByCardId.get(card.id) || null;
|
||||
const isRequestedInPendingRun = activeRunIndex >= 0;
|
||||
const currentRunTask =
|
||||
!pendingGeneration ||
|
||||
pendingGeneration.modeType !== "detail_replacement" ||
|
||||
isAgentReplacementTaskInPendingRun({
|
||||
task: latestTask,
|
||||
pendingStartedAt: pendingGeneration.timestamp,
|
||||
requestMessageId: pendingGeneration.requestMessageId,
|
||||
})
|
||||
? latestTask
|
||||
: null;
|
||||
return {
|
||||
...card,
|
||||
index,
|
||||
resultUrl,
|
||||
resultText,
|
||||
compareSourceUrl,
|
||||
taskQueueStatus: currentRunTask?.queueStatus || null,
|
||||
queuePosition:
|
||||
activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement"
|
||||
? activeRunIndex + 1
|
||||
@ -4939,14 +5099,18 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: 0,
|
||||
isGenerating: isDetailResultModeGenerating,
|
||||
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
||||
taskStatus: currentRunTask?.status || null,
|
||||
isRequestedInPendingRun,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
[
|
||||
detailReplacementReferenceCards,
|
||||
isDetailResultModeGenerating,
|
||||
latestDetailTaskByCardId,
|
||||
latestReplacementResults,
|
||||
normalizedDeletedResultCollections.detail_replacement,
|
||||
pendingGeneration,
|
||||
pendingGeneration?.modeType,
|
||||
pendingGeneration?.requestedCardIds,
|
||||
replacementResultState.detail_replacement,
|
||||
@ -5436,6 +5600,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (transportIsGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestAccepted = isAgentGenerationRequestAccepted({
|
||||
pending: pendingGeneration,
|
||||
@ -5483,7 +5650,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
|
||||
const timerId = window.setTimeout(clearUnacceptedGeneration, remainingMs);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [historyMessages, pendingGeneration, serverUserMessageCount, stop]);
|
||||
}, [
|
||||
historyMessages,
|
||||
pendingGeneration,
|
||||
serverUserMessageCount,
|
||||
stop,
|
||||
transportIsGenerating,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration) {
|
||||
@ -6452,6 +6625,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setPendingGeneration(null);
|
||||
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
|
||||
});
|
||||
if (replacementModeType === "detail_replacement") {
|
||||
window.setTimeout(() => {
|
||||
void refreshReplacementTasks();
|
||||
}, 500);
|
||||
}
|
||||
if (selectedAgentPreset?.type !== "image") {
|
||||
setInput("");
|
||||
}
|
||||
@ -6484,6 +6662,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
executableModelIdSet,
|
||||
lockedWorkflowModelId,
|
||||
requiredProductUploadCount,
|
||||
refreshReplacementTasks,
|
||||
requiresPresetSelection,
|
||||
selectedAgentPreset,
|
||||
selectedAgentPresetId,
|
||||
@ -10336,10 +10515,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
{isPending
|
||||
? card.status === "generating"
|
||||
? "正在重做这一屏,新的结果回来后会直接覆盖当前展示。"
|
||||
: Number(card.taskQueueStatus?.queuePosition || 0) > 0
|
||||
? `已进入生图队列,前方还有 ${card.taskQueueStatus?.queuePosition} 个同模型任务。`
|
||||
: card.queuePosition && card.queueTotal
|
||||
? `当前批次位次 ${card.queuePosition}/${card.queueTotal},前面的屏完成后会继续处理这一屏。`
|
||||
: "前面的屏生成完成后,会继续处理这一屏。"
|
||||
: card.resultUrl
|
||||
? `当前批次位次 ${card.queuePosition}/${card.queueTotal},已进入生图队列。`
|
||||
: "已进入生图队列,正在等待可用并发。"
|
||||
: card.status === "failed"
|
||||
? card.resultUrl
|
||||
? "本次重新生成失败,已保留上一次结果,可再次重试。"
|
||||
: "本次生成失败,可点击开始处理重新生成。"
|
||||
: card.resultUrl
|
||||
? card.status === "passthrough"
|
||||
? "当前参考图未检测到可替换产品,本屏先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。"
|
||||
: card.status === "fallback"
|
||||
|
||||
@ -18,11 +18,14 @@ export function isAgentGenerationRequestAccepted(params: {
|
||||
const requestMessageId = String(
|
||||
params.pending.requestMessageId || ""
|
||||
).trim();
|
||||
if (requestMessageId) {
|
||||
return params.serverMessages.some(
|
||||
if (
|
||||
requestMessageId &&
|
||||
params.serverMessages.some(
|
||||
(message) =>
|
||||
message.role === "user" && message.id === requestMessageId
|
||||
);
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const serverUserMessageCount = params.serverMessages.filter(
|
||||
|
||||
182
lib/agents/replacement-task-status.ts
Normal file
182
lib/agents/replacement-task-status.ts
Normal file
@ -0,0 +1,182 @@
|
||||
export type AgentReplacementTaskModeType =
|
||||
| "detail_replacement"
|
||||
| "sku_replacement";
|
||||
|
||||
export type AgentReplacementTaskStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
export interface AgentReplacementTaskSnapshot {
|
||||
id: string;
|
||||
modeType: AgentReplacementTaskModeType;
|
||||
referenceId: string;
|
||||
messageId?: string | null;
|
||||
status: AgentReplacementTaskStatus;
|
||||
error: string | null;
|
||||
result: unknown;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
queueStatus?: {
|
||||
queuePosition?: number | null;
|
||||
sameModelPendingCount?: number | null;
|
||||
sameModelRunningCount?: number | null;
|
||||
concurrencyLimit?: number | null;
|
||||
estimatedWaitMs?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type AgentReplacementCardStatus =
|
||||
| "waiting"
|
||||
| "queued"
|
||||
| "generating"
|
||||
| "completed"
|
||||
| "passthrough"
|
||||
| "fallback"
|
||||
| "failed"
|
||||
| "missing";
|
||||
|
||||
function toTimestamp(value: string | null | undefined) {
|
||||
const timestamp = Date.parse(String(value || ""));
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
export function buildLatestAgentReplacementTaskMap(params: {
|
||||
tasks: AgentReplacementTaskSnapshot[];
|
||||
modeType: AgentReplacementTaskModeType;
|
||||
}) {
|
||||
const result = new Map<string, AgentReplacementTaskSnapshot>();
|
||||
const orderedTasks = [...params.tasks].sort(
|
||||
(left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt)
|
||||
);
|
||||
|
||||
for (const task of orderedTasks) {
|
||||
if (
|
||||
task.modeType !== params.modeType ||
|
||||
!task.referenceId ||
|
||||
result.has(task.referenceId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
result.set(task.referenceId, task);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isAgentReplacementTaskInPendingRun(params: {
|
||||
task: AgentReplacementTaskSnapshot | null | undefined;
|
||||
pendingStartedAt?: number | null;
|
||||
requestMessageId?: string | null;
|
||||
clockSkewAllowanceMs?: number;
|
||||
}) {
|
||||
if (!params.task) {
|
||||
return false;
|
||||
}
|
||||
const taskMessageId = String(params.task.messageId || "").trim();
|
||||
const requestMessageId = String(params.requestMessageId || "").trim();
|
||||
if (taskMessageId && requestMessageId) {
|
||||
return taskMessageId === requestMessageId;
|
||||
}
|
||||
if (!Number.isFinite(Number(params.pendingStartedAt))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const taskCreatedAt = toTimestamp(params.task.createdAt);
|
||||
const pendingStartedAt = Number(params.pendingStartedAt);
|
||||
const clockSkewAllowanceMs = Math.max(0, params.clockSkewAllowanceMs ?? 15_000);
|
||||
return taskCreatedAt >= pendingStartedAt - clockSkewAllowanceMs;
|
||||
}
|
||||
|
||||
export function getAgentReplacementTaskResultImageUrl(result: unknown) {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const images = (result as { images?: unknown }).images;
|
||||
if (!Array.isArray(images)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const image of images) {
|
||||
if (!image || typeof image !== "object" || Array.isArray(image)) {
|
||||
continue;
|
||||
}
|
||||
const record = image as Record<string, unknown>;
|
||||
const originalUrl = String(record.originalUrl || "").trim();
|
||||
if (/^https?:\/\//i.test(originalUrl)) {
|
||||
return originalUrl;
|
||||
}
|
||||
const thumbnailUrl = String(record.thumbnailUrl || "").trim();
|
||||
if (/^https?:\/\//i.test(thumbnailUrl)) {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveAgentReplacementCardStatus(params: {
|
||||
resultUrl: string | null;
|
||||
persistedResultStatus?: "completed" | "passthrough" | "fallback" | null;
|
||||
taskStatus?: AgentReplacementTaskStatus | null;
|
||||
isRequestedInPendingRun: boolean;
|
||||
isGenerating: boolean;
|
||||
index: number;
|
||||
generatedCount: number;
|
||||
activeRunIndex?: number | null;
|
||||
}): AgentReplacementCardStatus {
|
||||
if (params.taskStatus === "pending") {
|
||||
return "queued";
|
||||
}
|
||||
if (params.taskStatus === "running") {
|
||||
return "generating";
|
||||
}
|
||||
|
||||
if (params.resultUrl) {
|
||||
if (
|
||||
params.persistedResultStatus === "passthrough" ||
|
||||
params.persistedResultStatus === "fallback"
|
||||
) {
|
||||
return params.persistedResultStatus;
|
||||
}
|
||||
if (params.taskStatus === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
if (params.taskStatus === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (params.taskStatus === "succeeded") {
|
||||
return "missing";
|
||||
}
|
||||
|
||||
if (params.isRequestedInPendingRun) {
|
||||
return "queued";
|
||||
}
|
||||
|
||||
if (
|
||||
typeof params.activeRunIndex === "number" &&
|
||||
params.activeRunIndex >= 0 &&
|
||||
params.generatedCount <= params.activeRunIndex
|
||||
) {
|
||||
if (params.isGenerating) {
|
||||
return params.generatedCount === params.activeRunIndex ? "generating" : "queued";
|
||||
}
|
||||
if (params.generatedCount > 0) {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
if (params.isGenerating) {
|
||||
return params.index === params.generatedCount ? "generating" : "queued";
|
||||
}
|
||||
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
|
||||
return "missing";
|
||||
}
|
||||
return "waiting";
|
||||
}
|
||||
@ -28,6 +28,7 @@ interface FalImageParams {
|
||||
systemPrompt?: string;
|
||||
thinkingLevel?: string;
|
||||
syncMode?: boolean;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface UploadedImage {
|
||||
@ -915,12 +916,24 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
|
||||
const fal = await createFalClientFromApiKey(apiKey);
|
||||
|
||||
const requestTimeoutMs = Math.max(
|
||||
30_000,
|
||||
Math.min(
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
Number(options.imageParams?.requestTimeoutMs || DEFAULT_TIMEOUT_MS)
|
||||
)
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const abortTimeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeoutMs);
|
||||
let result: any;
|
||||
try {
|
||||
result = await fal.subscribe(request.falEndpointId, {
|
||||
input: request.input,
|
||||
logs: true,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
timeout: requestTimeoutMs,
|
||||
abortSignal: abortController.signal,
|
||||
onQueueUpdate: (update: any) => {
|
||||
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
|
||||
for (const log of update.logs) {
|
||||
@ -934,6 +947,8 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
} catch (error) {
|
||||
console.error("[Fal Image] Request failed:", summarizeFalError(error));
|
||||
throw createDetailedFalError(error);
|
||||
} finally {
|
||||
clearTimeout(abortTimeoutId);
|
||||
}
|
||||
|
||||
const text = await normalizeFalResultToMarkdown({
|
||||
|
||||
@ -219,6 +219,8 @@ const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO = 0.26;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO = 0.55;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO = 0.5;
|
||||
const CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const CANVAS_DETAIL_REPLACEMENT_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const CANVAS_DETAIL_REPLACEMENT_FAL_REQUEST_TIMEOUT_MS = 170 * 1000;
|
||||
const CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const CANVAS_SCALE_ADJUST_SNAPGEN_REQUEST_TIMEOUT_MS = 90 * 1000;
|
||||
@ -260,6 +262,17 @@ function isCanvasScaleAdjustDebugRequest(
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasDetailReplacementDebugRequest(
|
||||
debugLabel: string,
|
||||
debugMeta?: Record<string, unknown>
|
||||
) {
|
||||
const operation = String(debugMeta?.operation || "");
|
||||
return (
|
||||
debugLabel === "agent-detail-replacement" ||
|
||||
operation === "agent-detail-replacement"
|
||||
);
|
||||
}
|
||||
|
||||
function getCanvasProviderBatchTimeoutMs(params: {
|
||||
debugLabel: string;
|
||||
debugMeta?: Record<string, unknown>;
|
||||
@ -271,6 +284,10 @@ function getCanvasProviderBatchTimeoutMs(params: {
|
||||
: CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
if (isCanvasDetailReplacementDebugRequest(params.debugLabel, params.debugMeta)) {
|
||||
return CANVAS_DETAIL_REPLACEMENT_PROVIDER_BATCH_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
return CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
@ -291,6 +308,15 @@ function getCanvasProviderRuntimeParams(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
params.providerType === "fal-image" &&
|
||||
isCanvasDetailReplacementDebugRequest(params.debugLabel, params.debugMeta)
|
||||
) {
|
||||
return {
|
||||
requestTimeoutMs: CANVAS_DETAIL_REPLACEMENT_FAL_REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@ -50,6 +50,22 @@ export type CanvasImageTaskQueueStatus = {
|
||||
source: "model_queue" | "batch_queue";
|
||||
};
|
||||
|
||||
export type CanvasDetailReplacementTaskContextSnapshot = {
|
||||
id: string;
|
||||
status: CanvasImageTaskStatus;
|
||||
modeType: "detail_replacement";
|
||||
referenceId: string;
|
||||
messageId: string | null;
|
||||
error: string | null;
|
||||
result: unknown;
|
||||
attempts: number;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
queueStatus: CanvasImageTaskQueueStatus | null;
|
||||
};
|
||||
|
||||
interface PendingTaskEntry {
|
||||
taskId: string;
|
||||
modelKey: string;
|
||||
@ -75,6 +91,7 @@ interface QueueState {
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const DETAIL_REPLACEMENT_RUNNING_TIMEOUT_MS = 18 * 60 * 1000;
|
||||
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
@ -133,6 +150,9 @@ function getCanvasImageRunningTimeoutMs(request: unknown) {
|
||||
const debugMeta = getRecord(record?.debugMeta);
|
||||
const debugLabel = String(record?.debugLabel || "");
|
||||
const operation = String(debugMeta?.operation || "");
|
||||
const isDetailReplacement =
|
||||
debugLabel === "agent-detail-replacement" ||
|
||||
operation === "agent-detail-replacement";
|
||||
const isScaleAdjust =
|
||||
debugLabel === "canvas-scale-adjust" ||
|
||||
operation === "scale-adjust" ||
|
||||
@ -140,8 +160,11 @@ function getCanvasImageRunningTimeoutMs(request: unknown) {
|
||||
Boolean(debugMeta?.scaleAdjustPatchForcedForShrink) ||
|
||||
Boolean(record?.scaleAdjustPatchComposite);
|
||||
|
||||
return isScaleAdjust
|
||||
? SCALE_ADJUST_RUNNING_TIMEOUT_MS
|
||||
if (isScaleAdjust) {
|
||||
return SCALE_ADJUST_RUNNING_TIMEOUT_MS;
|
||||
}
|
||||
return isDetailReplacement
|
||||
? DETAIL_REPLACEMENT_RUNNING_TIMEOUT_MS
|
||||
: DEFAULT_RUNNING_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
@ -889,3 +912,86 @@ export async function getCanvasImageTaskForUser(params: {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCanvasDetailReplacementTasksForUserContext(params: {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
projectId?: string | null;
|
||||
limit?: number;
|
||||
}): Promise<CanvasDetailReplacementTaskContextSnapshot[]> {
|
||||
const chatId = String(params.chatId || "").trim();
|
||||
const projectId = String(params.projectId || "").trim();
|
||||
if (!chatId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contextConditions = [
|
||||
eq(canvasImageTask.userId, params.userId),
|
||||
sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'chatId', '') = ${chatId}`,
|
||||
sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'operation', '') = 'agent-detail-replacement'`,
|
||||
];
|
||||
if (projectId) {
|
||||
contextConditions.push(
|
||||
sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'projectId', '') = ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: canvasImageTask.id,
|
||||
status: canvasImageTask.status,
|
||||
request: canvasImageTask.request,
|
||||
result: canvasImageTask.result,
|
||||
error: canvasImageTask.error,
|
||||
attempts: canvasImageTask.attempts,
|
||||
startedAt: canvasImageTask.startedAt,
|
||||
completedAt: canvasImageTask.completedAt,
|
||||
createdAt: canvasImageTask.createdAt,
|
||||
updatedAt: canvasImageTask.updatedAt,
|
||||
})
|
||||
.from(canvasImageTask)
|
||||
.where(and(...contextConditions))
|
||||
.orderBy(desc(canvasImageTask.createdAt))
|
||||
.limit(Math.max(1, Math.min(128, Math.floor(params.limit || 128))));
|
||||
|
||||
const snapshots: Array<CanvasDetailReplacementTaskContextSnapshot | null> = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const request = getRecord(row.request);
|
||||
const debugMeta = getRecord(request?.debugMeta);
|
||||
const referenceId = String(debugMeta?.referenceId || "").trim();
|
||||
const messageId = String(debugMeta?.messageId || "").trim() || null;
|
||||
if (!referenceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTask =
|
||||
row.status === "pending" || row.status === "running"
|
||||
? await getCanvasImageTaskForUser({
|
||||
taskId: row.id,
|
||||
userId: params.userId,
|
||||
})
|
||||
: null;
|
||||
const status = (activeTask?.status || row.status) as CanvasImageTaskStatus;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
status,
|
||||
modeType: "detail_replacement" as const,
|
||||
referenceId,
|
||||
messageId,
|
||||
error: activeTask?.error || row.error || null,
|
||||
result: activeTask?.result || row.result || null,
|
||||
attempts: activeTask?.attempts || row.attempts,
|
||||
startedAt: activeTask?.startedAt || row.startedAt,
|
||||
completedAt: activeTask?.completedAt || row.completedAt,
|
||||
createdAt: activeTask?.createdAt || row.createdAt,
|
||||
updatedAt: activeTask?.updatedAt || row.updatedAt,
|
||||
queueStatus: activeTask?.queueStatus || null,
|
||||
} satisfies CanvasDetailReplacementTaskContextSnapshot;
|
||||
})
|
||||
);
|
||||
|
||||
return snapshots.filter(
|
||||
(task): task is CanvasDetailReplacementTaskContextSnapshot => task !== null
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,6 +33,16 @@ expect(
|
||||
}),
|
||||
"the exact persisted request message must acknowledge the request"
|
||||
);
|
||||
expect(
|
||||
isAgentGenerationRequestAccepted({
|
||||
pending,
|
||||
serverMessages: [
|
||||
{ id: "request-1", role: "user" },
|
||||
{ id: "server-replaced-request-id", role: "user" },
|
||||
],
|
||||
}),
|
||||
"a newly persisted user turn must acknowledge the request even when its server id changes"
|
||||
);
|
||||
expect(
|
||||
isAgentGenerationRequestAccepted({
|
||||
pending: {
|
||||
|
||||
138
scripts/test-agent-replacement-task-status.ts
Normal file
138
scripts/test-agent-replacement-task-status.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import {
|
||||
buildLatestAgentReplacementTaskMap,
|
||||
getAgentReplacementTaskResultImageUrl,
|
||||
isAgentReplacementTaskInPendingRun,
|
||||
resolveAgentReplacementCardStatus,
|
||||
type AgentReplacementTaskSnapshot,
|
||||
} from "@/lib/agents/replacement-task-status";
|
||||
|
||||
function expect(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function createTask(
|
||||
overrides: Partial<AgentReplacementTaskSnapshot>
|
||||
): AgentReplacementTaskSnapshot {
|
||||
return {
|
||||
id: "task-1",
|
||||
modeType: "detail_replacement",
|
||||
referenceId: "detail-1",
|
||||
messageId: "request-1",
|
||||
status: "pending",
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "2026-08-01T08:00:00.000Z",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
queueStatus: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingTask = createTask({ status: "pending" });
|
||||
const runningTask = createTask({ status: "running" });
|
||||
const failedTask = createTask({ status: "failed", error: "provider timeout" });
|
||||
|
||||
expect(
|
||||
resolveAgentReplacementCardStatus({
|
||||
resultUrl: null,
|
||||
taskStatus: pendingTask.status,
|
||||
isRequestedInPendingRun: true,
|
||||
isGenerating: true,
|
||||
index: 0,
|
||||
generatedCount: 0,
|
||||
}) === "queued",
|
||||
"a database pending task must display as queued"
|
||||
);
|
||||
expect(
|
||||
resolveAgentReplacementCardStatus({
|
||||
resultUrl: null,
|
||||
taskStatus: runningTask.status,
|
||||
isRequestedInPendingRun: true,
|
||||
isGenerating: true,
|
||||
index: 0,
|
||||
generatedCount: 0,
|
||||
}) === "generating",
|
||||
"a database running task must display as generating"
|
||||
);
|
||||
expect(
|
||||
resolveAgentReplacementCardStatus({
|
||||
resultUrl: null,
|
||||
taskStatus: failedTask.status,
|
||||
isRequestedInPendingRun: false,
|
||||
isGenerating: false,
|
||||
index: 0,
|
||||
generatedCount: 0,
|
||||
}) === "failed",
|
||||
"a failed database task must display as failed"
|
||||
);
|
||||
expect(
|
||||
resolveAgentReplacementCardStatus({
|
||||
resultUrl: "https://cdn.example.com/previous-result.png",
|
||||
taskStatus: failedTask.status,
|
||||
isRequestedInPendingRun: false,
|
||||
isGenerating: false,
|
||||
index: 0,
|
||||
generatedCount: 0,
|
||||
}) === "failed",
|
||||
"a failed rerun must retain the prior image while displaying the failed status"
|
||||
);
|
||||
expect(
|
||||
resolveAgentReplacementCardStatus({
|
||||
resultUrl: null,
|
||||
taskStatus: null,
|
||||
isRequestedInPendingRun: true,
|
||||
isGenerating: true,
|
||||
index: 0,
|
||||
generatedCount: 0,
|
||||
}) === "queued",
|
||||
"a submitted card must display as queued before its database task is visible"
|
||||
);
|
||||
|
||||
const newerTask = createTask({
|
||||
id: "task-2",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-08-01T08:05:00.000Z",
|
||||
completedAt: "2026-08-01T08:06:00.000Z",
|
||||
result: {
|
||||
images: [
|
||||
{
|
||||
originalUrl: "https://cdn.example.com/detail-result.png",
|
||||
thumbnailUrl: "https://cdn.example.com/detail-result-thumb.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const latestTaskMap = buildLatestAgentReplacementTaskMap({
|
||||
tasks: [pendingTask, newerTask],
|
||||
modeType: "detail_replacement",
|
||||
});
|
||||
expect(
|
||||
latestTaskMap.get("detail-1")?.id === newerTask.id,
|
||||
"the latest task for each detail card must win"
|
||||
);
|
||||
expect(
|
||||
getAgentReplacementTaskResultImageUrl(newerTask.result) ===
|
||||
"https://cdn.example.com/detail-result.png",
|
||||
"a succeeded task must expose its original result image"
|
||||
);
|
||||
expect(
|
||||
!isAgentReplacementTaskInPendingRun({
|
||||
task: pendingTask,
|
||||
pendingStartedAt: Date.parse("2026-08-01T08:02:00.000Z"),
|
||||
requestMessageId: "request-2",
|
||||
}),
|
||||
"an old task must not control a newly submitted detail replacement run"
|
||||
);
|
||||
expect(
|
||||
isAgentReplacementTaskInPendingRun({
|
||||
task: newerTask,
|
||||
pendingStartedAt: Date.parse("2026-08-01T08:04:59.000Z"),
|
||||
requestMessageId: "request-1",
|
||||
}),
|
||||
"the task created for the current run must control its detail card"
|
||||
);
|
||||
|
||||
console.log("agent replacement task status tests passed");
|
||||
Loading…
x
Reference in New Issue
Block a user