From 0a81f15f11849feb255496a4fc92b905c59f4af5 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 31 Jul 2026 17:18:18 +0800 Subject: [PATCH] fix(agent): preserve detail replacement results --- app/api/agent-projects/[id]/route.ts | 167 +++++- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 487 ++++++++++++++---- lib/agents/generation-project-state.ts | 193 ++++++- types/agent.ts | 30 ++ 4 files changed, 763 insertions(+), 114 deletions(-) diff --git a/app/api/agent-projects/[id]/route.ts b/app/api/agent-projects/[id]/route.ts index 5dba7bead..1a6768c3d 100644 --- a/app/api/agent-projects/[id]/route.ts +++ b/app/api/agent-projects/[id]/route.ts @@ -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( + (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 +): AgentGenerationProjectResultCollections { + return RESULT_COLLECTION_KEYS.reduce( + (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 +): AgentGenerationProjectReplacementResultState { + return REPLACEMENT_RESULT_KEYS.reduce( + (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) diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 727a8b286..a9cfc0d58 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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; 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(null); const [drawingTargetRegionId, setDrawingTargetRegionId] = useState(null); const [mode, setMode] = useState("local_edit"); + const [imageSize, setImageSize] = useState("1K"); const dragStartRef = useRef<{ x: number; y: number } | null>(null); const editSurfaceRef = useRef(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) => { @@ -1817,6 +1841,36 @@ function DetailResultLocalEditDialog({ ? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}` : `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`} + {!activePreviewResult ? ( +
+
+ 输出尺寸 +
+
+ {( + [ + { label: "快速 1K", value: "1K" }, + { label: "高清 2K", value: "2K" }, + ] as const + ).map((option) => ( + + ))} +
+
+ ) : null} {!activePreviewResult && isScaleAdjustMode ? (
@@ -2352,13 +2406,7 @@ type ReplacementModeType = Extract< "detail_replacement" | "sku_replacement" >; -interface ReplacementResultRecord { - url: string | null; - text: string; - status: Extract; - 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(null); const [activeBusinessModeKey, setActiveBusinessModeKey] = - useState("main_sku"); + useState( + normalizedIncomingWorkspaceState.activeBusinessModeKey || "main_sku" + ); const [activeResultModeType, setActiveResultModeType] = useState("scene_generation"); const [fullscreenPreview, setFullscreenPreview] = useState(null); @@ -3213,7 +3279,7 @@ export function AgentGenerationWorkspaceDarkV2({ Record >({}); const [replacementResultState, setReplacementResultState] = useState( - () => createEmptyReplacementResultState() + () => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState) ); const [replacementExecutionContext, setReplacementExecutionContext] = useState(null); @@ -3229,6 +3295,7 @@ export function AgentGenerationWorkspaceDarkV2({ const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] = useState>(() => new Set()); const processedResultHintTimerRef = useRef>({}); + const transportGenerationStartedAtRef = useRef(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"} diff --git a/lib/agents/generation-project-state.ts b/lib/agents/generation-project-state.ts index b383cc741..be71b9f55 100644 --- a/lib/agents/generation-project-state.ts +++ b/lib/agents/generation-project-state.ts @@ -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([ + "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) + : {}; + + const normalizeModeState = (input: unknown) => { + const records = + input && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : {}; + const entries: Array< + readonly [ + string, + NonNullable[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; + 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 + | 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).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, }; } diff --git a/types/agent.ts b/types/agent.ts index 947aa02a8..abb543a5a 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -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 + > +>; + export interface AgentGenerationProjectWorkspaceState { input: string; attachments: Array; 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 {