diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 22b3ff26..3893316b 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -2171,6 +2171,78 @@ describe('WorkflowController', () => { ]) }) + it('一个方向进入重试后仍允许补跑同节点的其它失败方向', async () => { + const run = createRun([ + setupNode({ + status: 'passed', + phase: 'completed', + input: { prompt: '像素骑士', referenceMedia: [], characterId: 'character-1' }, + }), + templateNode({ status: 'passed', phase: 'completed', selectedImageUrl: 'east-template.png' }), + firstFrameNode({ + status: 'passed', + phase: 'completed', + selectedFirstFrameUrl: 'east-frame.png', + selectedFirstFrameUrls: { + east: 'east-frame.png', + north: 'north-frame.png', + south: 'south-frame.png', + }, + }), + generationMethodNode({ status: 'passed', phase: 'completed', method: 'video-cropping' }), + fullFrameNode({ + status: 'active', + phase: 'generating', + input: { prompt: '向前挥拳、击中后自然收势' }, + generations: [ + { taskId: 'retry-east', role: 'complete_animation' }, + { taskId: 'failed-north', role: 'complete_animation', direction: 'north' }, + { taskId: 'failed-south', role: 'complete_animation', direction: 'south' }, + ], + error: null, + }), + reviewNode(), + ]) + const { controller, generation } = createController(run, 'four-way') + generation.snapshots.set('retry-east', { + id: 'retry-east', + projectId: '1', + type: 'complete_animation', + status: 'pending', + result: null, + error: null, + }) + for (const direction of ['north', 'south'] as const) { + generation.snapshots.set(`failed-${direction}`, { + id: `failed-${direction}`, + projectId: '1', + type: 'complete_animation', + status: 'failed', + result: null, + error: `${direction} provider failed`, + }) + } + + await controller.retryGenerationDirection('action-walk:action-full-frame', 'north', { + spriteWidth: 64, + spriteHeight: 64, + }) + await controller.retryGenerationDirection('action-walk:action-full-frame', 'south', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(generation.apis.create).toHaveBeenCalledTimes(2) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ direction: 'north' }), + ) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ direction: 'south' }), + ) + }) + it('重试东向时只清空对应的兼容选择字段', async () => { const templateRetry = createController( createRun([ diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 5e3f2fc0..ed8cd29a 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1287,13 +1287,27 @@ export function createWorkflowController({ const originalNode = structuredClone(findNode(before, nodeId)) const role = generationRoleForNode(originalNode) if (!role) throw new Error('目标节点不是生成节点') - if (originalNode.status !== 'failed' && originalNode.phase !== 'selecting') { - throw new Error('当前方向不能重新生成') - } const reference = originalNode.generations.find( (item) => item.role === role && generationReferenceDirection(item) === direction, ) if (!reference) throw new Error(`方向 ${direction} 没有可替换的生成任务`) + if (originalNode.status !== 'failed' && originalNode.phase !== 'selecting') { + if (originalNode.status !== 'active' || originalNode.phase !== 'generating') { + throw new Error('当前方向不能重新生成') + } + const expectation = generationExpectationForNode( + before, + originalNode, + reference.direction, + reference.role, + ) + const generation = expectation + ? await generationApis.get(before.projectId, reference.taskId, expectation) + : null + if (generation?.status !== 'failed') { + throw new Error('当前方向不能重新生成') + } + } if (originalNode.type !== 'action-full-frame') { ensurePositiveInteger(options.spriteWidth, 'spriteWidth') ensurePositiveInteger(options.spriteHeight, 'spriteHeight') diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 45cb31ea..5824fd2c 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -3864,6 +3864,37 @@ describe('QuickStartPage', () => { ) }) + it('一次补跑同一节点当前所有失败方向并保留进行中反馈', async () => { + const run = actionWorkflow({ fullStatus: 'failed', error: '多个方向失败' }) + const firstRetry = deferred() + const service = serviceFor(run, { + getFailedGenerationDirections: vi.fn(async () => [ + { nodeId: 'action-full', direction: 'east' as const }, + { nodeId: 'action-full', direction: 'north' as const }, + { nodeId: 'action-full', direction: 'south' as const }, + ]), + retryGenerationDirection: vi + .fn() + .mockImplementationOnce(() => firstRetry.promise) + .mockResolvedValue(run), + }) + renderAt('/quick-start/run-1', service) + + expect( + await screen.findByLabelText('动作生成失败 已完成的方向会保留,点击下方可重试失败方向。'), + ).toBeTruthy() + const retry = await screen.findByRole('button', { name: '重试失败方向' }) + fireEvent.click(retry) + + const retrying = await screen.findByRole('button', { name: '正在重试失败方向…' }) + expect((retrying as HTMLButtonElement).disabled).toBe(true) + firstRetry.resolve(run) + await waitFor(() => expect(service.retryGenerationDirection).toHaveBeenCalledTimes(3)) + expect(service.retryGenerationDirection).toHaveBeenNthCalledWith(1, 'action-full', 'east') + expect(service.retryGenerationDirection).toHaveBeenNthCalledWith(2, 'action-full', 'north') + expect(service.retryGenerationDirection).toHaveBeenNthCalledWith(3, 'action-full', 'south') + }) + it('角色母版失败时也提供定向重试入口', async () => { const run = workflow( setupAndTemplate({ diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 717791fa..a40d76c3 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -2726,7 +2726,7 @@ function QuickStartRun({ readonly (readonly [string, string])[] >([]) const [failedDirections, setFailedDirections] = useState([]) - const [retryingDirection, setRetryingDirection] = useState(null) + const [retryingDirectionsForNode, setRetryingDirectionsForNode] = useState(null) const [exportModel, setExportModel] = useState(null) const [publishing, setPublishing] = useState(false) const [confirmingCandidate, setConfirmingCandidate] = useState(false) @@ -3439,22 +3439,34 @@ function QuickStartRun({ } } - async function retryFailedDirection(item: QuickStartFailedDirection) { + async function retryFailedDirections(items: readonly QuickStartFailedDirection[]) { const targetSession = session - if (!targetSession || workflowConflictRef.current) return - const key = `${item.nodeId}:${item.direction}` - setRetryingDirection(key) + const nodeId = items[0]?.nodeId + if (!targetSession || !nodeId || workflowConflictRef.current) return + setRetryingDirectionsForNode(nodeId) clearWorkflowError() + let latestRun: WorkflowRun | null = null + let firstFailure: unknown = null try { - const updated = await targetSession.retryGenerationDirection(item.nodeId, item.direction) - if (!mountedRef.current || activeSessionRef.current !== targetSession) return - setRun(updated) - } catch (cause) { + for (const item of items) { + try { + latestRun = await targetSession.retryGenerationDirection(item.nodeId, item.direction) + } catch (cause) { + firstFailure ??= cause + } + } if (!mountedRef.current || activeSessionRef.current !== targetSession) return - reportWorkflowError(cause, `重试${DIRECTION_LABELS[item.direction]}方向失败`) + if (latestRun) setRun(latestRun) + if (firstFailure) { + const fallback = + items.length === 1 + ? `重试${DIRECTION_LABELS[items[0]!.direction]}方向失败` + : '重试失败方向时仍有任务未能恢复' + reportWorkflowError(firstFailure, fallback) + } } finally { if (mountedRef.current && activeSessionRef.current === targetSession) { - setRetryingDirection(null) + setRetryingDirectionsForNode(null) } } } @@ -3462,25 +3474,18 @@ function QuickStartRun({ function DirectionRetryButtons({ nodeId }: { nodeId: string }) { const items = failedDirections.filter((item) => item.nodeId === nodeId) if (items.length === 0) return null + const retrying = retryingDirectionsForNode === nodeId + const label = + items.length === 1 ? `重试${DIRECTION_LABELS[items[0]!.direction]}方向` : '重试失败方向' return ( -
- {items.map((item) => { - const key = `${item.nodeId}:${item.direction}` - return ( - - ) - })} -
+ ) } @@ -3949,7 +3954,7 @@ function QuickStartRun({ <> @@ -4065,7 +4070,7 @@ function QuickStartRun({ <>