Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions frontend/src/features/workflow-controller/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/features/workflow-controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/pages/quick-start/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3864,6 +3864,37 @@ describe('QuickStartPage', () => {
)
})

it('一次补跑同一节点当前所有失败方向并保留进行中反馈', async () => {
const run = actionWorkflow({ fullStatus: 'failed', error: '多个方向失败' })
const firstRetry = deferred<WorkflowRun>()
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({
Expand Down
67 changes: 36 additions & 31 deletions frontend/src/pages/quick-start/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2726,7 +2726,7 @@ function QuickStartRun({
readonly (readonly [string, string])[]
>([])
const [failedDirections, setFailedDirections] = useState<readonly QuickStartFailedDirection[]>([])
const [retryingDirection, setRetryingDirection] = useState<string | null>(null)
const [retryingDirectionsForNode, setRetryingDirectionsForNode] = useState<string | null>(null)
const [exportModel, setExportModel] = useState<ExportPackageModel | null>(null)
const [publishing, setPublishing] = useState(false)
const [confirmingCandidate, setConfirmingCandidate] = useState(false)
Expand Down Expand Up @@ -3439,48 +3439,53 @@ 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)
Comment thread
xyh202131 marked this conversation as resolved.
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)
}
}
}

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 (
<div className="flex flex-wrap gap-2">
{items.map((item) => {
const key = `${item.nodeId}:${item.direction}`
return (
<button
key={key}
type="button"
onClick={() => void retryFailedDirection(item)}
disabled={retryingDirection !== null || workflowConflict}
className="rounded-lg border border-current px-3 py-1.5 text-xs font-bold text-app-danger disabled:opacity-50"
>
{retryingDirection === key
? `正在重试${DIRECTION_LABELS[item.direction]}方向…`
: `重试${DIRECTION_LABELS[item.direction]}方向`}
</button>
)
})}
</div>
<button
type="button"
onClick={() => void retryFailedDirections(items)}
disabled={retryingDirectionsForNode !== null || workflowConflict}
className="rounded-lg border border-current px-3 py-1.5 text-xs font-bold text-app-danger disabled:opacity-50"
>
{retrying ? `正在${label}…` : label}
</button>
)
}

Expand Down Expand Up @@ -3949,7 +3954,7 @@ function QuickStartRun({
<>
<AgentCopy
tone="danger"
lines={['动作首帧生成失败', '内容还在,可以在下面修改要求后重试。']}
lines={['动作首帧生成失败', '已完成的方向会保留,点击下方可重试失败方向。']}
/>
<DirectionRetryButtons nodeId={firstFrameStep.id} />
</>
Expand Down Expand Up @@ -4065,7 +4070,7 @@ function QuickStartRun({
<>
<AgentCopy
tone="danger"
lines={['动作生成失败', '内容还在,可以在下面修改要求后重试。']}
lines={['动作生成失败', '已完成的方向会保留,点击下方可重试失败方向。']}
/>
<DirectionRetryButtons nodeId={actionStep.id} />
</>
Expand Down
Loading