fix(agent): preserve detail replacement results

This commit is contained in:
134 2026-07-31 17:18:18 +08:00
parent 7c9fd8a796
commit 0a81f15f11
4 changed files with 763 additions and 114 deletions

View File

@ -8,6 +8,11 @@ import {
isAgentProjectRow,
} from "@/lib/agents/agent-project-compat";
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
import type {
AgentGenerationProjectPersistedState,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
} from "@/types/agent";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@ -25,6 +30,144 @@ function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) {
};
}
const RESULT_COLLECTION_KEYS = [
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as const;
const REPLACEMENT_RESULT_KEYS = [
"detail_replacement",
"sku_replacement",
] as const;
function normalizeProjectResultUrlList(urls: string[] | null | undefined) {
return Array.from(
new Set(
(urls || [])
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
}
function mergeResultCollections(
existing: AgentGenerationProjectResultCollections | null | undefined,
incoming: AgentGenerationProjectResultCollections | null | undefined
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(existing?.[key]),
...normalizeProjectResultUrlList(incoming?.[key]),
]);
return next;
},
{}
);
}
function getResultCollectionUrlSet(
collections: AgentGenerationProjectResultCollections | null | undefined
) {
return new Set(
RESULT_COLLECTION_KEYS.flatMap((key) => normalizeProjectResultUrlList(collections?.[key]))
);
}
function removeDeletedUrlsFromCollections(
collections: AgentGenerationProjectResultCollections,
deletedUrls: Set<string>
): AgentGenerationProjectResultCollections {
return RESULT_COLLECTION_KEYS.reduce<AgentGenerationProjectResultCollections>(
(next, key) => {
next[key] = normalizeProjectResultUrlList(collections[key]).filter(
(url) => !deletedUrls.has(url)
);
return next;
},
{}
);
}
function mergeReplacementResultState(
existing: AgentGenerationProjectReplacementResultState | null | undefined,
incoming: AgentGenerationProjectReplacementResultState | null | undefined,
deletedUrls: Set<string>
): AgentGenerationProjectReplacementResultState {
return REPLACEMENT_RESULT_KEYS.reduce<AgentGenerationProjectReplacementResultState>(
(next, key) => {
const existingModeState = existing?.[key] || {};
const incomingModeState = incoming?.[key] || {};
const mergedModeState = { ...existingModeState, ...incomingModeState };
next[key] = Object.fromEntries(
Object.entries(mergedModeState).filter(([, record]) => {
const url = String(record?.url || "").trim();
return !url || !deletedUrls.has(url);
})
);
return next;
},
{}
);
}
function getReplacementResultUrls(
state: AgentGenerationProjectReplacementResultState | null | undefined
) {
return REPLACEMENT_RESULT_KEYS.flatMap((key) =>
Object.values(state?.[key] || {})
.map((record) => String(record?.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
);
}
function getAllResultUrlsFromState(state: AgentGenerationProjectPersistedState) {
return normalizeProjectResultUrlList([
...normalizeProjectResultUrlList(state.resultImageUrls),
...RESULT_COLLECTION_KEYS.flatMap((key) =>
normalizeProjectResultUrlList(state.resultCollections?.[key])
),
...getReplacementResultUrls(state.replacementResultState),
]);
}
function mergeAgentProjectStateForSave(params: {
existingState: AgentGenerationProjectPersistedState;
incomingState: unknown;
}) {
const existingState = normalizeAgentGenerationProjectPersistedState(params.existingState);
const incomingState = normalizeAgentGenerationProjectPersistedState(params.incomingState);
const deletedResultCollections = mergeResultCollections(
existingState.deletedResultCollections,
incomingState.deletedResultCollections
);
const deletedUrls = getResultCollectionUrlSet(deletedResultCollections);
const replacementResultState = mergeReplacementResultState(
existingState.replacementResultState,
incomingState.replacementResultState,
deletedUrls
);
const resultCollections = removeDeletedUrlsFromCollections(
mergeResultCollections(existingState.resultCollections, incomingState.resultCollections),
deletedUrls
);
return normalizeAgentGenerationProjectPersistedState({
...incomingState,
resultCollections,
deletedResultCollections,
replacementResultState,
resultImageUrls: normalizeProjectResultUrlList([
...getAllResultUrlsFromState(existingState),
...getAllResultUrlsFromState(incomingState),
...RESULT_COLLECTION_KEYS.flatMap((key) => resultCollections[key] || []),
...getReplacementResultUrls(replacementResultState),
]).filter((url) => !deletedUrls.has(url)),
});
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
@ -79,16 +222,28 @@ export async function PUT(
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
}
const title = body.title !== undefined ? String(body.title || "").trim() || "未命名生图项目" : undefined;
const title =
body.title !== undefined
? String(body.title || "").trim() || "未命名生图项目"
: existingProject.title;
const description =
body.description !== undefined ? String(body.description || "").trim() || null : undefined;
const thumbnail =
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
body.description !== undefined
? String(body.description || "").trim() || null
: existingProject.description || null;
const existingProjectState = extractAgentProjectStateFromCanvasData(existingProject.canvasData);
const nextProjectState =
body.projectState !== undefined
? normalizeAgentGenerationProjectPersistedState(body.projectState)
: extractAgentProjectStateFromCanvasData(existingProject.canvasData);
? mergeAgentProjectStateForSave({
existingState: existingProjectState,
incomingState: body.projectState,
})
: existingProjectState;
const firstResultUrl = getAllResultUrlsFromState(nextProjectState)[0] || null;
const requestedThumbnail =
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
const thumbnail =
requestedThumbnail || existingProject.thumbnail || firstResultUrl || null;
const [project] = await db
.update(canvasProject)

View File

@ -82,6 +82,7 @@ import {
LOCAL_EDIT_MAX_REGIONS,
createLocalEditReferenceImageId,
createLocalEditRegionId,
hasScaleAdjustTargetChanged,
reindexLocalEditRegions,
type LocalEditReferenceImage,
type LocalEditRegion,
@ -122,6 +123,8 @@ import type {
AgentDetailBriefForm,
AgentDetailGenerationProgressState,
AgentDetailReplacementStrategy,
AgentGenerationProjectReplacementResultState,
AgentGenerationReplacementResultRecord,
AgentGenerationProjectWorkspaceState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
@ -146,6 +149,7 @@ import {
import {
createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState,
normalizeAgentGenerationProjectReplacementResultState,
} from "@/lib/agents/generation-project-state";
export interface AgentGenerationWorkspaceDarkV2Props {
@ -173,6 +177,10 @@ export interface AgentGenerationWorkspaceDarkV2Props {
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
resultCollections?: AgentGenerationProjectResultCollections | null;
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
replacementResultState?: AgentGenerationProjectReplacementResultState | null;
onReplacementResultStateChange?: (
next: AgentGenerationProjectReplacementResultState
) => void;
onResultPreviewUrlsChange?: (
modeType: AgentGenerationResultModeType,
urls: string[]
@ -181,6 +189,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
modeType: AgentGenerationResultModeType,
imageUrl: string
) => void;
onPersistProject?: () => Promise<boolean> | boolean;
topNavRightSlot?: ReactNode;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
workflowStylePanel?: ReactNode;
@ -247,6 +256,8 @@ type DetailResultLocalEditTarget = {
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
type DetailResultLocalEditImageSize = "1K" | "2K";
type DetailResultLocalEditRect = {
x: number;
y: number;
@ -269,6 +280,7 @@ type DetailResultLocalEditRegionInteraction = {
type DetailResultLocalEditSubmitParams = {
imageUrl: string;
imageSize: DetailResultLocalEditImageSize;
mode: DetailResultLocalEditMode;
title: string;
regions: LocalEditRegion[];
@ -469,6 +481,15 @@ function normalizeResultCollectionUrls(urls?: string[] | null) {
);
}
function areResultUrlListsEqual(left?: string[] | null, right?: string[] | null) {
const normalizedLeft = normalizeResultCollectionUrls(left);
const normalizedRight = normalizeResultCollectionUrls(right);
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((url, index) => normalizedRight[index] === url)
);
}
function excludeDeletedResultUrls(
urls: string[] | undefined | null,
deletedUrls: string[] | undefined | null
@ -894,6 +915,7 @@ function DetailResultLocalEditDialog({
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
const [imageSize, setImageSize] = useState<DetailResultLocalEditImageSize>("1K");
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
const isScaleAdjustMode = mode === "scale_adjust";
@ -918,6 +940,7 @@ function DetailResultLocalEditDialog({
setRegionInteraction(null);
setUploadingRegionId(null);
setDrawingTargetRegionId(null);
setImageSize("1K");
dragStartRef.current = null;
}, [target?.cardId, target?.imageUrl, target?.mode]);
@ -1211,7 +1234,7 @@ function DetailResultLocalEditDialog({
return;
}
const unchangedRegion = validRegions.find(
(region) => deriveDetailScalePercentFromRects(region.rect, region.targetRect) === 0
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
);
if (unchangedRegion) {
setActiveRegionId(unchangedRegion.id);
@ -1228,11 +1251,12 @@ function DetailResultLocalEditDialog({
}
onSubmit({
imageUrl: target.imageUrl,
imageSize,
mode,
title: target.title,
regions: validRegions,
});
}, [isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
}, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
const completeDraftRegion = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@ -1817,6 +1841,36 @@ function DetailResultLocalEditDialog({
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
</div>
{!activePreviewResult ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
输出尺寸
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ label: "快速 1K", value: "1K" },
{ label: "高清 2K", value: "2K" },
] as const
).map((option) => (
<button
className={cn(
"h-9 rounded-xl border px-3 text-sm font-semibold transition",
imageSize === option.value
? "border-white bg-white text-black"
: "border-white/10 bg-black/20 text-white/58 hover:bg-white/[0.08] hover:text-white"
)}
disabled={submitting}
key={option.value}
onClick={() => setImageSize(option.value)}
type="button"
>
{option.label}
</button>
))}
</div>
</div>
) : null}
{!activePreviewResult && isScaleAdjustMode ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
@ -2352,13 +2406,7 @@ type ReplacementModeType = Extract<
"detail_replacement" | "sku_replacement"
>;
interface ReplacementResultRecord {
url: string | null;
text: string;
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
updatedAt: number;
compareSourceUrl?: string | null;
}
interface ReplacementResultRecord extends AgentGenerationReplacementResultRecord {}
interface ReplacementExecutionContext {
modeType: ReplacementModeType;
@ -2405,13 +2453,26 @@ type ReplacementResultState = Record<
const MANUAL_PASSTHROUGH_RESULT_TEXT =
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
function createEmptyReplacementResultState(): ReplacementResultState {
function normalizeReplacementResultStateForWorkspace(
value: AgentGenerationProjectReplacementResultState | null | undefined
): ReplacementResultState {
const normalized = normalizeAgentGenerationProjectReplacementResultState(value);
return {
detail_replacement: {},
sku_replacement: {},
detail_replacement: { ...(normalized.detail_replacement || {}) },
sku_replacement: { ...(normalized.sku_replacement || {}) },
};
}
function areReplacementResultStatesEqual(
left: ReplacementResultState,
right: ReplacementResultState
) {
return (
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
);
}
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
const normalized = normalizeAssistantText(String(text || ""))
.replace(/\s+/g, " ")
@ -3059,9 +3120,12 @@ export function AgentGenerationWorkspaceDarkV2({
workspaceState = null,
resultCollections = null,
deletedResultCollections = null,
replacementResultState: persistedReplacementResultState = null,
onWorkspaceStateChange,
onReplacementResultStateChange,
onResultPreviewUrlsChange,
onDeleteResultImage,
onPersistProject,
topNavRightSlot,
showcaseConfig,
workflowStylePanel,
@ -3151,7 +3215,9 @@ export function AgentGenerationWorkspaceDarkV2({
);
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [activeBusinessModeKey, setActiveBusinessModeKey] =
useState<AgentWorkflowBusinessModeKey>("main_sku");
useState<AgentWorkflowBusinessModeKey>(
normalizedIncomingWorkspaceState.activeBusinessModeKey || "main_sku"
);
const [activeResultModeType, setActiveResultModeType] =
useState<AgentGenerationResultModeType>("scene_generation");
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
@ -3213,7 +3279,7 @@ export function AgentGenerationWorkspaceDarkV2({
Record<string, string>
>({});
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
() => createEmptyReplacementResultState()
() => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState)
);
const [replacementExecutionContext, setReplacementExecutionContext] =
useState<ReplacementExecutionContext | null>(null);
@ -3229,6 +3295,7 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
useState<Set<string>>(() => new Set());
const processedResultHintTimerRef = useRef<Record<string, number>>({});
const transportGenerationStartedAtRef = useRef<number | null>(null);
const closePsdTemplateDialog = useCallback(() => {
setIsPsdTemplateDialogOpen(false);
setPsdTemplateDialogSourceUrl(null);
@ -3262,6 +3329,22 @@ export function AgentGenerationWorkspaceDarkV2({
},
[activeResultModeType, isPsdTemplateEnabled]
);
useEffect(() => {
const nextState = normalizeReplacementResultStateForWorkspace(
persistedReplacementResultState
);
setReplacementResultState((current) =>
areReplacementResultStatesEqual(current, nextState) ? current : nextState
);
}, [persistedReplacementResultState]);
useEffect(() => {
onReplacementResultStateChange?.(
normalizeAgentGenerationProjectReplacementResultState(replacementResultState)
);
}, [onReplacementResultStateChange, replacementResultState]);
const normalizedDeletedResultCollections = useMemo(
() => ({
scene_generation: normalizeResultCollectionUrls(
@ -4510,63 +4593,48 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards,
]);
useEffect(() => {
if (latestReplacementResults.length === 0) {
return;
}
const detailReferenceCardById = new Map(
detailReplacementReferenceCards.map((card) => [card.id, card])
);
const skuReferenceCardById = new Map(
skuReplacementReferenceCards.map((card) => [card.id, card])
);
const fallbackModeType: ReplacementModeType | null =
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"
? currentResultModeType
: null;
setReplacementResultState((current) => {
const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id));
const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id));
let changed = false;
const nextDetail = Object.fromEntries(
Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId))
);
if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) {
changed = true;
}
const nextSku = Object.fromEntries(
Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId))
);
if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) {
changed = true;
}
if (!changed) {
return current;
}
return {
detail_replacement: nextDetail,
sku_replacement: nextSku,
const nextState: ReplacementResultState = {
detail_replacement: { ...current.detail_replacement },
sku_replacement: { ...current.sku_replacement },
};
});
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
useEffect(() => {
if (
(currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") ||
latestReplacementResults.length === 0
) {
return;
}
const cardIds = currentReplacementExecutionCardIds;
if (cardIds.length === 0) {
return;
}
setReplacementResultState((current) => {
const nextModeState = { ...current[currentResultModeType] };
let changed = false;
latestReplacementResults.forEach((result, index) => {
const cardId = result.replacementCardId || cardIds[index];
const applyResult = (
modeType: ReplacementModeType,
cardId: string | null | undefined,
result: {
url: string | null;
text: string;
replacementCardId: string | null;
},
referenceCard?: {
imageUrl?: string | null;
label?: string | null;
previewUrl?: string | null;
} | null
) => {
if (!cardId) {
return;
}
const inferredStatus = inferReplacementCardStatusFromText(result.text);
const nextModeState = nextState[modeType];
const previousRecord = nextModeState[cardId];
const referenceCard =
currentResultModeType === "detail_replacement"
? detailReplacementReferenceCards.find((card) => card.id === cardId)
: skuReplacementReferenceCards.find((card) => card.id === cardId);
const resultUrl = result.url || null;
const previousUrl =
previousRecord?.url && previousRecord.url !== resultUrl
@ -4586,27 +4654,59 @@ export function AgentGenerationWorkspaceDarkV2({
resultUrl,
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
),
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
referencePreviewUrl:
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
label: referenceCard?.label || previousRecord?.label || null,
};
if (
previousRecord?.url === nextRecord.url &&
previousRecord?.text === nextRecord.text &&
previousRecord?.status === nextRecord.status &&
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
previousRecord?.label === nextRecord.label
) {
return;
}
nextModeState[cardId] = nextRecord;
changed = true;
};
latestReplacementResults.forEach((result, index) => {
const explicitCardId = result.replacementCardId || null;
if (explicitCardId) {
const detailReferenceCard = detailReferenceCardById.get(explicitCardId);
if (detailReferenceCard) {
applyResult("detail_replacement", explicitCardId, result, detailReferenceCard);
return;
}
const skuReferenceCard = skuReferenceCardById.get(explicitCardId);
if (skuReferenceCard) {
applyResult("sku_replacement", explicitCardId, result, skuReferenceCard);
return;
}
}
if (!fallbackModeType) {
return;
}
const fallbackCardId = currentReplacementExecutionCardIds[index];
const fallbackReferenceCard =
fallbackModeType === "detail_replacement"
? detailReferenceCardById.get(fallbackCardId)
: skuReferenceCardById.get(fallbackCardId);
applyResult(fallbackModeType, fallbackCardId, result, fallbackReferenceCard);
});
if (!changed) {
return current;
}
return {
...current,
[currentResultModeType]: nextModeState,
};
return nextState;
});
}, [
currentReplacementExecutionCardIds,
@ -4616,8 +4716,8 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards,
]);
const detailReplacementResultUrls = useMemo(
() =>
detailReplacementReferenceCards
() => {
const urlsFromCards = detailReplacementReferenceCards
.map((card) => {
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
@ -4627,7 +4727,15 @@ export function AgentGenerationWorkspaceDarkV2({
? nextUrl
: null;
})
.filter((url): url is string => Boolean(url)),
.filter((url): url is string => Boolean(url));
const urlsFromRecords = Object.values(replacementResultState.detail_replacement)
.map((record) => record.url)
.filter((url): url is string => Boolean(url))
.filter(
(url) => !normalizedDeletedResultCollections.detail_replacement.includes(url)
);
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
},
[
detailReplacementReferenceCards,
normalizedDeletedResultCollections.detail_replacement,
@ -4635,15 +4743,21 @@ export function AgentGenerationWorkspaceDarkV2({
]
);
const skuReplacementResultUrls = useMemo(
() =>
skuReplacementReferenceCards
() => {
const urlsFromCards = skuReplacementReferenceCards
.map((card) => {
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
? nextUrl
: null;
})
.filter((url): url is string => Boolean(url)),
.filter((url): url is string => Boolean(url));
const urlsFromRecords = Object.values(replacementResultState.sku_replacement)
.map((record) => record.url)
.filter((url): url is string => Boolean(url))
.filter((url) => !normalizedDeletedResultCollections.sku_replacement.includes(url));
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
},
[
normalizedDeletedResultCollections.sku_replacement,
replacementResultState.sku_replacement,
@ -4734,24 +4848,20 @@ export function AgentGenerationWorkspaceDarkV2({
resultCollections?.detail_generation,
normalizedDeletedResultCollections.detail_generation
),
detail_replacement: Array.from(
new Set([
...detailReplacementResultUrls,
...excludeDeletedResultUrls(
resultCollections?.detail_replacement,
normalizedDeletedResultCollections.detail_replacement
),
])
),
sku_replacement: Array.from(
new Set([
...skuReplacementResultUrls,
...excludeDeletedResultUrls(
resultCollections?.sku_replacement,
normalizedDeletedResultCollections.sku_replacement
),
])
),
detail_replacement:
detailReplacementResultUrls.length > 0
? detailReplacementResultUrls
: excludeDeletedResultUrls(
resultCollections?.detail_replacement,
normalizedDeletedResultCollections.detail_replacement
),
sku_replacement:
skuReplacementResultUrls.length > 0
? skuReplacementResultUrls
: excludeDeletedResultUrls(
resultCollections?.sku_replacement,
normalizedDeletedResultCollections.sku_replacement
),
};
return {
@ -4957,6 +5067,52 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementBatchDownloadUrls.length > 0
? skuReplacementBatchDownloadUrls
: mergedResultCollections.sku_replacement;
const syncReplacementResultCollectionsToParent = useCallback(
(targetModeType?: ReplacementModeType) => {
if (!onResultPreviewUrlsChange) {
return;
}
const syncMode = (modeType: ReplacementModeType, liveUrls: string[]) => {
if (targetModeType && targetModeType !== modeType) {
return;
}
const normalizedLiveUrls = normalizeResultCollectionUrls(liveUrls);
if (normalizedLiveUrls.length === 0) {
return;
}
const deletedUrlSet = new Set(
normalizedDeletedResultCollections[modeType]
);
const nextUrls = Array.from(
new Set(normalizedLiveUrls.filter((url) => !deletedUrlSet.has(url)))
);
if (!areResultUrlListsEqual(resultCollections?.[modeType], nextUrls)) {
onResultPreviewUrlsChange(modeType, nextUrls);
}
};
onReplacementResultStateChange?.(
normalizeAgentGenerationProjectReplacementResultState(
replacementResultState
)
);
syncMode("detail_replacement", detailReplacementResultUrls);
syncMode("sku_replacement", skuReplacementResultUrls);
},
[
detailReplacementResultUrls,
normalizedDeletedResultCollections,
onReplacementResultStateChange,
onResultPreviewUrlsChange,
replacementResultState,
resultCollections,
skuReplacementResultUrls,
]
);
const detailRemainingCount = useMemo(
() =>
detailResultCards.filter(
@ -5196,6 +5352,11 @@ export function AgentGenerationWorkspaceDarkV2({
};
return areImageGenerationParamsEqual(current, nextParams) ? current : nextParams;
});
setActiveBusinessModeKey((current) => {
const nextBusinessModeKey =
normalizedIncomingWorkspaceState.activeBusinessModeKey || "main_sku";
return current === nextBusinessModeKey ? current : nextBusinessModeKey;
});
}, [isWorkflowModelLocked, normalizedIncomingWorkspaceState]);
useEffect(() => {
@ -5212,6 +5373,7 @@ export function AgentGenerationWorkspaceDarkV2({
: null
),
selectedModelId,
activeBusinessModeKey,
imageParams: {
aspectRatio: imageParams.aspectRatio,
imageSize: imageParams.imageSize,
@ -5221,6 +5383,7 @@ export function AgentGenerationWorkspaceDarkV2({
workspaceStateSignatureRef.current = JSON.stringify(nextWorkspaceState);
onWorkspaceStateChange?.(nextWorkspaceState);
}, [
activeBusinessModeKey,
attachments,
imageParams.aspectRatio,
imageParams.imageCount,
@ -5272,6 +5435,15 @@ export function AgentGenerationWorkspaceDarkV2({
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
try {
stop();
} catch (error) {
console.warn(
"[AgentGenerationWorkspaceDarkV2] Failed to stop stale generation transport:",
error
);
}
transportGenerationStartedAtRef.current = null;
setPendingGeneration((current) => {
if (
!current ||
@ -5292,7 +5464,57 @@ export function AgentGenerationWorkspaceDarkV2({
const timerId = window.setTimeout(clearStalePendingGeneration, remainingMs);
return () => window.clearTimeout(timerId);
}, [pendingGeneration]);
}, [pendingGeneration, stop]);
useEffect(() => {
if (!transportIsGenerating) {
transportGenerationStartedAtRef.current = null;
return;
}
if (transportGenerationStartedAtRef.current === null) {
transportGenerationStartedAtRef.current =
pendingGeneration?.timestamp || Date.now();
}
const startedAt = transportGenerationStartedAtRef.current;
const elapsedMs = Date.now() - startedAt;
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
const abortStaleTransport = () => {
const currentStartedAt = transportGenerationStartedAtRef.current;
if (
currentStartedAt === null ||
Date.now() - currentStartedAt < AGENT_PENDING_GENERATION_EXPIRY_MS
) {
return;
}
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
try {
stop();
} catch (error) {
console.warn(
"[AgentGenerationWorkspaceDarkV2] Failed to abort stale generation transport:",
error
);
}
transportGenerationStartedAtRef.current = null;
setPendingGeneration(null);
toast.warning("生成请求超过 30 分钟未回传,已中断卡住连接,可重新生成未回传结果。");
};
if (remainingMs <= 0) {
abortStaleTransport();
return;
}
const timerId = window.setTimeout(abortStaleTransport, remainingMs);
return () => window.clearTimeout(timerId);
}, [pendingGeneration?.timestamp, stop, transportIsGenerating]);
useEffect(() => {
if (!pendingGeneration || transportIsGenerating) {
@ -5331,11 +5553,19 @@ export function AgentGenerationWorkspaceDarkV2({
]);
useEffect(() => {
onResultPreviewUrlsChange?.(
currentResultModeType,
mergedResultCollections[currentResultModeType] || []
);
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
if (!onResultPreviewUrlsChange) {
return;
}
([
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as AgentGenerationResultModeType[]).forEach((modeType) => {
onResultPreviewUrlsChange(modeType, mergedResultCollections[modeType] || []);
});
}, [mergedResultCollections, onResultPreviewUrlsChange]);
useEffect(() => {
if (activeResultModeUrls.length === 0) {
@ -5477,6 +5707,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: string[];
zipName: string;
scope: BatchDownloadScope;
modeType?: AgentGenerationResultModeType;
}) => {
const normalizedUrls = (params.imageUrls || [])
.map((item) => String(item || "").trim())
@ -5486,6 +5717,19 @@ export function AgentGenerationWorkspaceDarkV2({
return;
}
if (
params.modeType === "detail_replacement" ||
params.modeType === "sku_replacement"
) {
syncReplacementResultCollectionsToParent(params.modeType);
}
const persisted = await Promise.resolve(onPersistProject?.() ?? true).catch(
() => false
);
if (!persisted) {
toast.info("结果已保留在当前页面,项目同步稍后会自动重试");
}
setBatchDownloadState(params.scope);
setBatchDownloadDialog({
scope: params.scope,
@ -5565,7 +5809,7 @@ export function AgentGenerationWorkspaceDarkV2({
}));
toast.error(message);
}
}, []);
}, [onPersistProject, syncReplacementResultCollectionsToParent]);
useEffect(() => {
const taskId = batchDownloadDialog?.taskId;
@ -5664,6 +5908,9 @@ export function AgentGenerationWorkspaceDarkV2({
const link = document.createElement("a");
link.href = batchDownloadDialog.downloadUrl;
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
@ -5729,6 +5976,10 @@ export function AgentGenerationWorkspaceDarkV2({
setReplacementResultState((current) => {
const currentModeState = current[params.modeType];
const previousRecord = currentModeState[params.cardId];
const referenceCard =
params.modeType === "detail_replacement"
? detailReplacementReferenceCards.find((card) => card.id === params.cardId)
: skuReplacementReferenceCards.find((card) => card.id === params.cardId);
const inferredStatus = inferReplacementCardStatusFromText(params.text);
const explicitCompareSourceUrl =
params.compareSourceUrl === undefined
@ -5749,13 +6000,20 @@ export function AgentGenerationWorkspaceDarkV2({
explicitCompareSourceUrl === undefined
? previousRecord?.compareSourceUrl || null
: explicitCompareSourceUrl,
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
referencePreviewUrl:
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
label: referenceCard?.label || previousRecord?.label || null,
};
if (
previousRecord?.url === nextRecord.url &&
previousRecord?.text === nextRecord.text &&
previousRecord?.status === nextRecord.status &&
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
previousRecord?.label === nextRecord.label
) {
return current;
}
@ -5768,7 +6026,7 @@ export function AgentGenerationWorkspaceDarkV2({
},
};
});
}, []);
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
const handleOpenReplacementTextEdit = useCallback((params: {
modeType: ReplacementModeType;
cardId: string;
@ -6712,15 +6970,19 @@ export function AgentGenerationWorkspaceDarkV2({
: selectedModelId !== "auto"
? selectedModelId
: effectiveDefaultModelId || undefined;
const requestedImageSize: DetailResultLocalEditImageSize =
params.imageSize === "2K" ? "2K" : "1K";
setDetailLocalEditSubmittingId(cardId);
setDetailLocalEditSubmittingMode(editMode);
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑...");
toast.info(
`${isScaleAdjust ? "正在提交比例调整" : "正在提交局部编辑"}(${requestedImageSize})...`
);
try {
const submitResult = await imageGenerationService.localEdit({
aspectRatio: "auto",
imageSize: "2K",
imageSize: requestedImageSize,
model:
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
? resolvedLocalEditModelId
@ -6938,6 +7200,23 @@ export function AgentGenerationWorkspaceDarkV2({
delete next[normalizedUrl];
return next;
});
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
setReplacementResultState((current) => {
const currentModeState = current[modeType];
const nextModeState = Object.fromEntries(
Object.entries(currentModeState).filter(
([, record]) => record.url !== normalizedUrl
)
);
if (Object.keys(nextModeState).length === Object.keys(currentModeState).length) {
return current;
}
return {
...current,
[modeType]: nextModeState,
};
});
}
onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除");
@ -9416,6 +9695,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: detailReplacementOrderedDownloadUrls,
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
scope: "detail",
modeType: "detail_replacement",
})
}
disabled={batchDownloadState === "detail"}
@ -9433,6 +9713,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: mergedResultCollections.detail_generation,
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
scope: "detail",
modeType: "detail_generation",
})
}
disabled={batchDownloadState === "detail"}
@ -9450,6 +9731,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: skuReplacementOrderedDownloadUrls,
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
scope: "sku",
modeType: "sku_replacement",
})
}
disabled={batchDownloadState === "sku"}
@ -9466,6 +9748,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: activeResultModeUrls,
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
scope: "scene",
modeType: "scene_generation",
})
}
disabled={batchDownloadState === "scene"}

View File

@ -8,9 +8,12 @@ import {
} from "@/lib/agents/workflow-material-config";
import type {
AgentGenerationProjectPersistedState,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
AgentGenerationReplacementResultStatus,
AgentGenerationProjectWorkspaceState,
AgentWorkflowBusinessModeKey,
AgentWorkflowPersistedState,
} from "@/types/agent";
@ -19,6 +22,7 @@ export function createEmptyAgentGenerationProjectWorkspaceState(): AgentGenerati
input: "",
attachments: [],
selectedModelId: "auto",
activeBusinessModeKey: "main_sku",
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
@ -58,6 +62,7 @@ export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerati
resultImageUrls: [],
resultCollections: {},
deletedResultCollections: {},
replacementResultState: {},
};
}
@ -86,6 +91,129 @@ function normalizeResultCollections(value: unknown): AgentGenerationProjectResul
};
}
const REPLACEMENT_RESULT_STATUSES = new Set<AgentGenerationReplacementResultStatus>([
"completed",
"passthrough",
"fallback",
]);
function normalizeOptionalHttpUrl(value: unknown) {
const raw = String(value || "").trim();
return /^https?:\/\//i.test(raw) ? raw : null;
}
function normalizeReplacementResultStatus(value: unknown): AgentGenerationReplacementResultStatus {
return REPLACEMENT_RESULT_STATUSES.has(value as AgentGenerationReplacementResultStatus)
? (value as AgentGenerationReplacementResultStatus)
: "completed";
}
export function normalizeAgentGenerationProjectReplacementResultState(
value: unknown
): AgentGenerationProjectReplacementResultState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const normalizeModeState = (input: unknown) => {
const records =
input && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const entries: Array<
readonly [
string,
NonNullable<AgentGenerationProjectReplacementResultState["detail_replacement"]>[string],
]
> = [];
Object.entries(records).forEach(([rawCardId, rawRecord]) => {
const cardId = String(rawCardId || "").trim();
if (!cardId || !rawRecord || typeof rawRecord !== "object" || Array.isArray(rawRecord)) {
return;
}
const record = rawRecord as Record<string, unknown>;
const resultUrl = normalizeOptionalHttpUrl(record.url);
const text = String(record.text || "").trim();
const compareSourceUrl = normalizeOptionalHttpUrl(record.compareSourceUrl);
const referenceImageUrl = normalizeOptionalHttpUrl(record.referenceImageUrl);
const referencePreviewUrl = normalizeOptionalHttpUrl(record.referencePreviewUrl);
const label = String(record.label || "").trim() || null;
const updatedAt = Number(record.updatedAt);
if (!resultUrl && !text && !compareSourceUrl && !referenceImageUrl && !referencePreviewUrl) {
return;
}
entries.push([
cardId.slice(0, 160),
{
url: resultUrl,
text,
status: normalizeReplacementResultStatus(record.status),
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? Math.round(updatedAt) : 0,
compareSourceUrl,
referenceImageUrl,
referencePreviewUrl,
label,
},
]);
});
return Object.fromEntries(entries.slice(0, 256));
};
return {
detail_replacement: normalizeModeState(raw.detail_replacement),
sku_replacement: normalizeModeState(raw.sku_replacement),
};
}
function getReplacementResultUrls(
value:
| NonNullable<AgentGenerationProjectReplacementResultState["detail_replacement"]>
| undefined
| null
) {
return Array.from(
new Set(
Object.values(value || {})
.map((record) => String(record?.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 128);
}
function reconcileReplacementResultCollections(params: {
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
const detailReplacementUrls = getReplacementResultUrls(
params.replacementResultState.detail_replacement
);
const skuReplacementUrls = getReplacementResultUrls(
params.replacementResultState.sku_replacement
);
if (detailReplacementUrls.length === 0 && skuReplacementUrls.length === 0) {
return params.resultCollections;
}
return {
...params.resultCollections,
detail_replacement:
detailReplacementUrls.length > 0
? detailReplacementUrls
: params.resultCollections.detail_replacement,
sku_replacement:
skuReplacementUrls.length > 0
? skuReplacementUrls
: params.resultCollections.sku_replacement,
} satisfies AgentGenerationProjectResultCollections;
}
function inferLegacyResultModeType(
workflowState: AgentWorkflowPersistedState,
resultUrls: string[]
@ -210,11 +338,19 @@ function normalizeWorkspaceState(value: unknown): AgentGenerationProjectWorkspac
: {};
const imageCount = Number(rawImageParams.imageCount);
const activeBusinessModeKey = String(raw.activeBusinessModeKey || "").trim();
const normalizedBusinessModeKey: AgentWorkflowBusinessModeKey =
activeBusinessModeKey === "detail" ||
activeBusinessModeKey === "buyer_show" ||
activeBusinessModeKey === "main_sku"
? activeBusinessModeKey
: "main_sku";
return {
input: String(raw.input || "").trim(),
attachments,
selectedModelId: String(raw.selectedModelId || "").trim() || "auto",
activeBusinessModeKey: normalizedBusinessModeKey,
imageParams: {
aspectRatio: String(rawImageParams.aspectRatio || "").trim() || "auto",
imageSize: String(rawImageParams.imageSize || "").trim() || "2K",
@ -223,6 +359,36 @@ function normalizeWorkspaceState(value: unknown): AgentGenerationProjectWorkspac
};
}
function hasExplicitWorkspaceBusinessModeKey(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
return Boolean(String((value as Record<string, unknown>).activeBusinessModeKey || "").trim());
}
function inferWorkspaceBusinessModeKey(params: {
workflowState: AgentWorkflowPersistedState;
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}): AgentWorkflowBusinessModeKey {
const detailReferenceCount =
params.workflowState.agentModeState?.detailReplacement?.references?.length || 0;
const skuReferenceCount =
params.workflowState.agentModeState?.skuReplacement?.references?.length || 0;
const normalizedResultCollections = normalizeResultCollections(params.resultCollections);
const hasDetailResults =
(normalizedResultCollections.detail_generation || []).length > 0 ||
(normalizedResultCollections.detail_replacement || []).length > 0 ||
(normalizedResultCollections.sku_replacement || []).length > 0 ||
getReplacementResultUrls(params.replacementResultState.detail_replacement).length > 0 ||
getReplacementResultUrls(params.replacementResultState.sku_replacement).length > 0;
return detailReferenceCount > 0 || skuReferenceCount > 0 || hasDetailResults
? "detail"
: "main_sku";
}
function normalizeWorkflowState(value: unknown): AgentWorkflowPersistedState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
@ -287,18 +453,33 @@ export function normalizeAgentGenerationProjectPersistedState(
).slice(0, 64)
: [];
const workflowState = normalizeWorkflowState(raw.workflowState);
const resultCollections = normalizeMigratedResultCollections({
workflowState,
resultImageUrls,
resultCollections: normalizeResultCollections(raw.resultCollections),
});
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
const replacementResultState = normalizeAgentGenerationProjectReplacementResultState(
raw.replacementResultState
);
const resultCollections = reconcileReplacementResultCollections({
replacementResultState,
resultCollections: normalizeMigratedResultCollections({
workflowState,
resultImageUrls,
resultCollections: normalizeResultCollections(raw.resultCollections),
}),
});
const workspaceState = normalizeWorkspaceState(raw.workspaceState);
if (!hasExplicitWorkspaceBusinessModeKey(raw.workspaceState)) {
workspaceState.activeBusinessModeKey = inferWorkspaceBusinessModeKey({
workflowState,
resultCollections,
replacementResultState,
});
}
return {
workflowState,
workspaceState: normalizeWorkspaceState(raw.workspaceState),
workspaceState,
resultImageUrls,
resultCollections,
deletedResultCollections,
replacementResultState,
};
}

View File

@ -495,10 +495,39 @@ export interface AgentGenerationProjectResultCollections {
sku_replacement?: string[];
}
export type AgentGenerationReplacementModeType = Extract<
AgentGenerationResultModeType,
"detail_replacement" | "sku_replacement"
>;
export type AgentGenerationReplacementResultStatus =
| "completed"
| "passthrough"
| "fallback";
export interface AgentGenerationReplacementResultRecord {
url: string | null;
text: string;
status: AgentGenerationReplacementResultStatus;
updatedAt: number;
compareSourceUrl?: string | null;
referenceImageUrl?: string | null;
referencePreviewUrl?: string | null;
label?: string | null;
}
export type AgentGenerationProjectReplacementResultState = Partial<
Record<
AgentGenerationReplacementModeType,
Record<string, AgentGenerationReplacementResultRecord>
>
>;
export interface AgentGenerationProjectWorkspaceState {
input: string;
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
selectedModelId?: string | null;
activeBusinessModeKey?: AgentWorkflowBusinessModeKey;
imageParams?: AgentGenerationProjectImageParams;
}
@ -530,6 +559,7 @@ export interface AgentGenerationProjectPersistedState {
resultImageUrls?: string[];
resultCollections?: AgentGenerationProjectResultCollections;
deletedResultCollections?: AgentGenerationProjectResultCollections;
replacementResultState?: AgentGenerationProjectReplacementResultState;
}
export interface AgentGenerationProject {