diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 33f2131..d6841b6 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -35,6 +35,9 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 必须与 Project 的精灵尺寸一致,后端会在提交时校验。 */ + spriteWidth: number + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts index 51bb1bc..3acccbd 100644 --- a/frontend/src/entities/workflow-run/api.test.ts +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -201,4 +201,82 @@ describe('workflowRunApis', () => { kind: 'invalid-response', }) }) + + it('rejects a character node with status passed but phase not completed', async () => { + const corruptNode = { + ...nodes[0], + status: 'passed' as const, + phase: 'configuring_character' as const, + selectedImageUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [corruptNode, nodes[1]] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects an action node with status passed but phase not completed', async () => { + const corruptNode = { + ...nodes[1], + status: 'passed' as const, + phase: 'configuring_action' as const, + selectedFirstFrameUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [nodes[0], corruptNode] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a node with status locked but phase completed', async () => { + const corruptNode = { + ...nodes[0], + status: 'locked' as const, + phase: 'completed' as const, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [corruptNode] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a node with status active but phase completed', async () => { + const corruptNode = { + ...nodes[0], + status: 'active' as const, + phase: 'completed' as const, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [corruptNode] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a node with status failed but phase completed', async () => { + const corruptNode = { + ...nodes[1], + status: 'failed' as const, + phase: 'completed' as const, + error: 'something went wrong', + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [nodes[0], corruptNode] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) }) diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index 2e64314..9d68d61 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -57,6 +57,12 @@ function hasValidCommonNodeFields(value: Record): boolean { ) { return false } + // status/phase matrix: passed => completed; failed => not completed; locked/active => not completed + if (value.status === 'passed' && value.phase !== 'completed') return false + if (value.status === 'failed' && value.phase === 'completed') return false + if ((value.status === 'locked' || value.status === 'active') && value.phase === 'completed') { + return false + } return value.status === 'failed' ? typeof value.error === 'string' && value.error.trim().length > 0 : value.error === null diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md new file mode 100644 index 0000000..5d77961 --- /dev/null +++ b/frontend/src/features/workflow-controller/README.md @@ -0,0 +1,29 @@ +# WorkflowController + +`WorkflowController` 是页面与实体接口之间的业务协调器。一个实例只绑定一条 +`WorkflowRun`;它同时持有当前数据和修改这份数据的业务方法。 + +## 两种入口 + +- Workflow Editor 等待用户逐步调用生成、确认和审核方法。 +- Quick Start 用 AI 自动做选择并连续调用同一组方法。 + +两者界面和交互不同,但不会各自维护另一套工作流状态机。Controller 本身也不保存 +`driver`,因为“由谁点击”不改变节点图的业务规则。 + +## 边界 + +- `entities/workflow-run` 定义纯数据和异步 CRUD,不包含推进方法。 +- Controller 根据 `dependsOnNodeIds` 解锁节点,允许同一依赖下的多个 Action 并行。 +- Generation 通过 `nodeId + taskId` 写回;节点重做后,旧任务的迟到结果会被丢弃。 +- WorkflowRun 只有在后端 `update` 成功后才替换内存快照,保存失败不会向页面假报成功。 +- Generation 已创建但任务引用暂时保存失败时,本实例会保留待附加记录;重试同一命令或 + `resume()` 会复用原任务,不会再次创建和重复计费。 +- 中断只停止前端自动处理和 SSE。当前后端没有取消接口,因此不会伪装成已取消任务; + 恢复时先订阅再查询任务快照,既能拿终态,也不会漏掉查询与订阅之间的完成事件。 +- Controller 不包含页面、Playtest、后端实现、发布和导出逻辑。 + +## 文件 + +- `controller.ts`:单 WorkflowRun 的业务方法、持久化串行化和 Generation 恢复。 +- `controller.test.ts`:节点依赖、并行、中断、重做、异步竞争和持久化失败测试。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 0000000..97fd280 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,638 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { + CharacterWorkflowNode, + Generation, + GenerationApis, + GenerationEvent, + WorkflowActionInput, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { createWorkflowController } from '.' + +function characterNode(overrides: Partial = {}): CharacterWorkflowNode { + return { + id: 'character-1', + type: 'character', + status: 'active', + phase: 'configuring_character', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '像素骑士', referenceMedia: [] }, + selectedImageUrl: null, + ...overrides, + } +} + +function actionInput(overrides: Partial = {}): WorkflowActionInput { + return { + outfitId: 'outfit-1', + name: '行走', + type: 'walk', + prompt: null, + fps: 12, + ...overrides, + } +} + +function createRun(nodes: WorkflowNode[] = [characterNode()]): WorkflowRun { + return { + id: 'run-1', + projectId: '1', + version: 1, + storageStatus: 'active', + nodes, + } +} + +function createWorkflowApis(initial: WorkflowRun = createRun()) { + let saved = structuredClone(initial) + const apis: WorkflowRunApis = { + create: vi.fn(async (input) => { + saved = { + id: 'run-1', + projectId: input.projectId, + version: 1, + storageStatus: 'active', + nodes: structuredClone(input.nodes), + } + return structuredClone(saved) + }), + get: vi.fn(async () => structuredClone(saved)), + update: vi.fn(async (run) => { + saved = { ...structuredClone(run), version: saved.version + 1 } + return structuredClone(saved) + }), + remove: vi.fn(async () => undefined), + } + return { apis, getSaved: () => structuredClone(saved) } +} + +function createGenerationHarness() { + const listeners = new Map void>() + const snapshots = new Map() + let nextId = 1 + const apis: GenerationApis = { + create: vi.fn(async (input) => { + const generation: Generation = { + id: `task-${nextId++}`, + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + } + snapshots.set(generation.id, generation) + return generation + }) as GenerationApis['create'], + get: vi.fn(async (_projectId, id) => { + const generation = snapshots.get(id) + if (!generation) throw new Error(`Generation 不存在:${id}`) + return structuredClone(generation) + }), + subscribe: vi.fn((_projectId, id, onEvent) => { + listeners.set(id, onEvent) + return () => listeners.delete(id) + }), + } + + function emit(event: GenerationEvent) { + snapshots.set(event.taskId, { + id: event.taskId, + projectId: '1', + type: event.type, + status: event.status, + result: event.result, + error: event.error, + }) + listeners.get(event.taskId)?.(event) + } + + return { apis, emit, listeners, snapshots } +} + +function createController(run = createRun()) { + const workflow = createWorkflowApis(run) + const generation = createGenerationHarness() + const asyncErrors: Error[] = [] + const controller = createWorkflowController({ + workflow: run, + workflowRunApis: workflow.apis, + generationApis: generation.apis, + createId: () => 'action-created', + onAsyncError: (error) => asyncErrors.push(error), + }) + return { controller, workflow, generation, asyncErrors } +} + +async function flushAsyncWork() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('WorkflowController', () => { + it('一个实例只绑定一条 WorkflowRun,创建后不能换成另一条', async () => { + const workflow = createWorkflowApis() + const generation = createGenerationHarness() + const controller = createWorkflowController({ + workflowRunApis: workflow.apis, + generationApis: generation.apis, + onAsyncError: vi.fn(), + }) + + const created = await controller.create({ projectId: '1', nodes: [characterNode()] }) + + expect(controller.getWorkflow()).toEqual(created) + await expect( + controller.create({ projectId: '2', nodes: [characterNode({ id: 'other' })] }), + ).rejects.toThrow('已经绑定') + }) + + it('角色通过后按显式依赖边同时解锁多个 Action', async () => { + const run = createRun([ + characterNode({ phase: 'selecting_character' }), + { + id: 'action-walk', + type: 'action', + status: 'locked', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + }, + { + id: 'action-jump', + type: 'action', + status: 'locked', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput({ name: '跳跃', type: 'jump' }), + selectedFirstFrameUrl: null, + }, + ]) + const { controller } = createController(run) + + const next = await controller.confirmCharacter('character-1', 'https://img/knight.png') + + expect(next.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'character-1', status: 'passed', phase: 'completed' }), + expect.objectContaining({ id: 'action-walk', status: 'active' }), + expect.objectContaining({ id: 'action-jump', status: 'active' }), + ]), + ) + }) + + it('角色生成任务落库并从终态事件进入候选确认阶段', async () => { + const { controller, workflow, generation, asyncErrors } = createController() + + await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + expect(generation.apis.create).toHaveBeenCalledWith( + expect.objectContaining({ spriteWidth: 64, spriteHeight: 64 }), + ) + const inFlight = workflow.getSaved().nodes[0] + expect(inFlight).toMatchObject({ + phase: 'generating_character_candidates', + generations: [{ taskId: 'task-1', role: 'character_candidates' }], + }) + + generation.emit({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/knight.png' }], + }, + error: null, + }) + await flushAsyncWork() + + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + status: 'active', + phase: 'selecting_character', + error: null, + }) + expect(asyncErrors).toEqual([]) + }) + + it('SSE 与紧随其后的查询同时返回终态时只保存一次结果', async () => { + const workflow = createWorkflowApis() + const terminalEvent: GenerationEvent = { + taskId: 'task-terminal', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/knight.png' }], + }, + error: null, + } + const generationApis: GenerationApis = { + create: vi.fn(async () => ({ + id: 'task-terminal', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + })) as GenerationApis['create'], + get: vi.fn(async () => ({ + id: terminalEvent.taskId, + projectId: '1', + type: terminalEvent.type, + status: terminalEvent.status, + result: terminalEvent.result, + error: terminalEvent.error, + })), + subscribe: vi.fn((_projectId, _taskId, onEvent) => { + onEvent(terminalEvent) + return () => undefined + }), + } + const controller = createWorkflowController({ + workflow: createRun(), + workflowRunApis: workflow.apis, + generationApis, + onAsyncError: vi.fn(), + }) + + await controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + + expect(workflow.apis.update).toHaveBeenCalledTimes(2) + expect(controller.getWorkflow().nodes[0].phase).toBe('selecting_character') + }) + + it('中断后忽略迟到结果,恢复时查询终态再推进', async () => { + const { controller, generation } = createController() + await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + await controller.interrupt() + + generation.emit({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/knight.png' }], + }, + error: null, + }) + await flushAsyncWork() + expect(controller.getWorkflow().nodes[0].phase).toBe('generating_character_candidates') + + await controller.resume() + expect(controller.getWorkflow().nodes[0].phase).toBe('selecting_character') + }) + + it('从节点重做会清掉下游和旧 task,旧事件不能覆盖新执行线', async () => { + const run = createRun([ + characterNode({ + phase: 'generating_character_candidates', + generations: [{ taskId: 'task-old', role: 'character_candidates' }], + }), + { + id: 'action-walk', + type: 'action', + status: 'locked', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + }, + ]) + const { controller } = createController(run) + + await controller.restartFromNode('character-1') + await controller.applyGenerationResult({ + nodeId: 'character-1', + taskId: 'task-old', + generation: { + id: 'task-old', + projectId: '1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://img/stale.png' }], + }, + error: null, + }, + }) + + expect(controller.getWorkflow().nodes).toEqual([ + expect.objectContaining({ + id: 'character-1', + status: 'active', + phase: 'configuring_character', + generations: [], + }), + expect.objectContaining({ id: 'action-walk', status: 'locked', generations: [] }), + ]) + }) + + it('生成请求尚未返回时重做,旧任务不能挂回新执行线', async () => { + const workflow = createWorkflowApis() + const pendingResolvers: Array<(generation: Generation) => void> = [] + const snapshots = new Map() + const createGeneration = vi.fn( + () => + new Promise((resolve) => { + pendingResolvers.push((generation) => { + snapshots.set(generation.id, generation) + resolve(generation) + }) + }), + ) as unknown as GenerationApis['create'] + const generationApis: GenerationApis = { + create: createGeneration, + get: vi.fn(async (_projectId, id) => structuredClone(snapshots.get(id)!)), + subscribe: vi.fn(() => () => undefined), + } + const controller = createWorkflowController({ + workflow: createRun(), + workflowRunApis: workflow.apis, + generationApis, + onAsyncError: vi.fn(), + }) + + const oldSubmission = controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + await Promise.resolve() + await controller.restartFromNode('character-1') + + const newSubmission = controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + await Promise.resolve() + expect(createGeneration).toHaveBeenCalledTimes(2) + + pendingResolvers[0]?.({ + id: 'task-old', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + await oldSubmission + const sameNewSubmission = controller.generateCharacter('character-1', { + spriteWidth: 64, + spriteHeight: 64, + }) + expect(createGeneration).toHaveBeenCalledTimes(2) + + pendingResolvers[1]?.({ + id: 'task-new', + projectId: '1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + await Promise.all([newSubmission, sameNewSubmission]) + + expect(controller.getWorkflow().nodes[0].generations).toEqual([ + { taskId: 'task-new', role: 'character_candidates' }, + ]) + }) + + it('保存失败时不发布未落库的新状态', async () => { + const { controller, workflow } = createController( + createRun([characterNode({ phase: 'selecting_character' })]), + ) + vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) + + await expect( + controller.confirmCharacter('character-1', 'https://img/knight.png'), + ).rejects.toThrow('后端保存失败') + + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + status: 'active', + phase: 'selecting_character', + selectedImageUrl: null, + }) + }) + + it('生成任务创建成功但引用保存失败时,重试复用同一个任务', async () => { + const { controller, workflow, generation } = createController() + vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) + + await expect( + controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + ).rejects.toThrow('后端保存失败') + expect(controller.getWorkflow().nodes[0].generations).toEqual([]) + + await controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + expect(controller.getWorkflow().nodes[0]).toMatchObject({ + phase: 'generating_character_candidates', + generations: [{ taskId: 'task-1', role: 'character_candidates' }], + }) + }) + + it('同一节点并发点击只创建一个生成任务', async () => { + const { controller, generation } = createController() + + await Promise.all([ + controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + controller.generateCharacter('character-1', { spriteWidth: 64, spriteHeight: 64 }), + ]) + + expect(generation.apis.create).toHaveBeenCalledTimes(1) + }) + + it('完整动画必须是 32 帧,通过审核后节点才完成', async () => { + const frames = Array.from({ length: 32 }, (_, index) => ({ + url: `https://img/frame-${index}.png`, + })) + const run = createRun([ + characterNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + { + id: 'action-walk', + type: 'action', + status: 'active', + phase: 'generating_animation', + dependsOnNodeIds: ['character-1'], + generations: [{ taskId: 'task-animation', role: 'animation' }], + error: null, + input: actionInput(), + selectedFirstFrameUrl: 'https://img/first.png', + }, + ]) + const { controller } = createController(run) + + await controller.applyGenerationResult({ + nodeId: 'action-walk', + taskId: 'task-animation', + generation: { + id: 'task-animation', + projectId: '1', + type: 'complete_animation', + status: 'completed', + result: { type: 'complete_animation', frames }, + error: null, + }, + }) + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'active', + phase: 'reviewing_animation', + }) + + await controller.approveAction('action-walk') + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'passed', + phase: 'completed', + }) + }) + + it('同一 Action 节点依次生成首帧和 32 帧动画', async () => { + const run = createRun([ + characterNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + { + id: 'action-walk', + type: 'action', + status: 'active', + phase: 'configuring_action', + dependsOnNodeIds: ['character-1'], + generations: [], + error: null, + input: actionInput(), + selectedFirstFrameUrl: null, + }, + ]) + const { controller, generation } = createController(run) + + await controller.generateActionFrame('action-walk', { + characterId: 'character-backend-1', + referenceMedia: [], + }) + generation.emit({ + taskId: 'task-1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: 'https://img/first.png' } }, + error: null, + }) + await flushAsyncWork() + await controller.confirmActionFrame('action-walk', 'https://img/first.png') + + await controller.generateAnimation('action-walk', { + characterId: 'character-backend-1', + referenceMedia: [], + }) + generation.emit({ + taskId: 'task-2', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: Array.from({ length: 32 }, (_, index) => ({ + url: `https://img/frame-${index}.png`, + })), + }, + error: null, + }) + await flushAsyncWork() + await controller.approveAction('action-walk') + + expect(generation.apis.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: 'first_frame', + characterId: 'character-backend-1', + outfitId: 'outfit-1', + }), + ) + expect(generation.apis.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'complete_animation', + firstFrameUrl: 'https://img/first.png', + }), + ) + expect(controller.getWorkflow().nodes[1]).toMatchObject({ + status: 'passed', + phase: 'completed', + generations: [ + { taskId: 'task-1', role: 'action_frame_candidates' }, + { taskId: 'task-2', role: 'animation' }, + ], + }) + }) + + it('恢复动画阶段时不会让旧首帧任务把节点倒退', async () => { + const run = createRun([ + characterNode({ + status: 'passed', + phase: 'completed', + selectedImageUrl: 'https://img/knight.png', + }), + { + id: 'action-walk', + type: 'action', + status: 'active', + phase: 'generating_animation', + dependsOnNodeIds: ['character-1'], + generations: [ + { taskId: 'task-first-frame', role: 'action_frame_candidates' }, + { taskId: 'task-animation', role: 'animation' }, + ], + error: null, + input: actionInput(), + selectedFirstFrameUrl: 'https://img/first.png', + }, + ]) + const { controller, generation } = createController(run) + generation.snapshots.set('task-first-frame', { + id: 'task-first-frame', + projectId: '1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: 'https://img/first.png' } }, + error: null, + }) + generation.snapshots.set('task-animation', { + id: 'task-animation', + projectId: '1', + type: 'complete_animation', + status: 'running', + result: null, + error: null, + }) + + await controller.resume() + await controller.applyGenerationResult({ + nodeId: 'action-walk', + taskId: 'task-first-frame', + generation: generation.snapshots.get('task-first-frame')!, + }) + + expect(generation.apis.get).toHaveBeenCalledTimes(1) + expect(generation.apis.get).toHaveBeenCalledWith('1', 'task-animation') + expect(controller.getWorkflow().nodes[1].phase).toBe('generating_animation') + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 0000000..2a17e40 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,796 @@ +import type { + ActionWorkflowNode, + CharacterTemplateGenerationInput, + CharacterWorkflowNode, + CompleteAnimationGenerationInput, + CreateWorkflowRunInput, + FirstFrameGenerationInput, + Generation, + GenerationApis, + GenerationEvent, + MediaReference, + WorkflowActionInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' + +const COMPLETE_ANIMATION_FRAME_COUNT = 32 + +export interface AddActionInput { + /** 未传时由 Controller 生成,仅用于前端节点图。 */ + nodeId?: WorkflowNode['id'] + /** 默认依赖当前图中的 Character 节点。 */ + dependsOnNodeIds?: readonly WorkflowNode['id'][] + input: WorkflowActionInput +} + +export interface GenerateCharacterOptions { + spriteWidth: number + spriteHeight: number +} + +export interface GenerateActionOptions { + characterId: string + /** 由上传/媒体边界提供,Controller 不把展示 URL 冒充 MediaReference。 */ + referenceMedia: readonly MediaReference[] +} + +export interface ApplyGenerationResultInput { + nodeId: WorkflowNode['id'] + taskId: Generation['id'] + generation: Generation +} + +export interface CreateWorkflowControllerOptions { + /** 已从 WorkflowRunApis.get 取回的运行记录;不传时只能先调用 create。 */ + workflow?: WorkflowRun + workflowRunApis: WorkflowRunApis + generationApis: GenerationApis + createId?: () => string + /** SSE 回调无法 await,异步保存错误通过此处交给装配层展示或记录。 */ + onAsyncError: (error: Error) => void +} + +/** + * 一个 Controller 只维护一条 WorkflowRun。 + * + * Quick Start 与 Workflow Editor 调用同一组业务方法,区别只在于前者自动选择并连续 + * 调用、后者等待用户逐步点击。Controller 不识别入口,也不保存第二份流程模型。 + */ +export interface WorkflowController { + create(input: CreateWorkflowRunInput): Promise + getWorkflow(): WorkflowRun + + addAction(input: AddActionInput): Promise + generateCharacter( + nodeId: CharacterWorkflowNode['id'], + options: GenerateCharacterOptions, + ): Promise + confirmCharacter( + nodeId: CharacterWorkflowNode['id'], + selectedImageUrl: string, + ): Promise + generateActionFrame( + nodeId: ActionWorkflowNode['id'], + options: GenerateActionOptions, + ): Promise + confirmActionFrame( + nodeId: ActionWorkflowNode['id'], + selectedFirstFrameUrl: string, + ): Promise + generateAnimation( + nodeId: ActionWorkflowNode['id'], + options: GenerateActionOptions, + ): Promise + approveAction(nodeId: ActionWorkflowNode['id']): Promise + + /** 刷新恢复时查询已记录的 Generation,再恢复 SSE。 */ + resume(): Promise + /** 停止本实例的自动处理;后端没有 cancel,所以不会伪装成取消了服务端任务。 */ + interrupt(): Promise + restartFromNode(nodeId: WorkflowNode['id']): Promise + applyGenerationResult(input: ApplyGenerationResultInput): Promise + getGeneration( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + ): Promise + dispose(): void +} + +interface ActiveSubscription { + nodeId: WorkflowNode['id'] + taskId: Generation['id'] + stop: () => void +} + +interface PendingGenerationAttachment { + nodeId: WorkflowNode['id'] + role: WorkflowGenerationRole + expectedEpoch: number + generation: Generation +} + +export function createWorkflowController({ + workflow, + workflowRunApis, + generationApis, + createId = createBrowserSafeId, + onAsyncError, +}: CreateWorkflowControllerOptions): WorkflowController { + let current = workflow ? structuredClone(workflow) : null + let interrupted = false + let saveQueue: Promise = Promise.resolve() + const submissions = new Map>() + const subscriptions = new Map() + const nodeEpochs = new Map() + const unattachedGenerations = new Map() + const settlements = new Map>() + + function requireWorkflow(): WorkflowRun { + if (!current) throw new Error('WorkflowController 尚未绑定 WorkflowRun') + return current + } + + function snapshot(): WorkflowRun { + return structuredClone(requireWorkflow()) + } + + function ensureRunning() { + if (interrupted) throw new Error('WorkflowController 已中断,请先调用 resume') + } + + function enqueue(operation: () => Promise): Promise { + const result = saveQueue.then(operation) + saveQueue = result.then( + () => undefined, + () => undefined, + ) + return result + } + + function persist(transform: (run: WorkflowRun) => WorkflowRun): Promise { + return enqueue(async () => { + const before = requireWorkflow() + const candidate = transform(before) + if (candidate === before) return structuredClone(before) + + // 只有后端确认保存后才替换内存快照;失败时页面不会看到“假成功”。 + const saved = await workflowRunApis.update(candidate) + current = structuredClone(saved) + return structuredClone(saved) + }) + } + + function create(input: CreateWorkflowRunInput): Promise { + return enqueue(async () => { + if (current) throw new Error('WorkflowController 已经绑定一条 WorkflowRun') + const created = await workflowRunApis.create({ + ...input, + nodes: normalizeAvailability(input.nodes), + }) + current = structuredClone(created) + return structuredClone(created) + }) + } + + function getWorkflow() { + return snapshot() + } + + function addAction({ nodeId = createId(), dependsOnNodeIds, input }: AddActionInput) { + ensureRunning() + return persist((run) => { + if (run.nodes.some((node) => node.id === nodeId)) { + throw new Error(`WorkflowNode 已存在:${nodeId}`) + } + const dependencies = dependsOnNodeIds + ? [...dependsOnNodeIds] + : run.nodes.filter((node) => node.type === 'character').map((node) => node.id) + assertDependenciesExist(run.nodes, dependencies) + const node: ActionWorkflowNode = { + id: nodeId, + type: 'action', + status: dependencies.every((id) => isPassed(run.nodes, id)) ? 'active' : 'locked', + phase: 'configuring_action', + dependsOnNodeIds: dependencies, + generations: [], + error: null, + input: structuredClone(input), + selectedFirstFrameUrl: null, + } + return { ...run, nodes: [...run.nodes, node] } + }) + } + + function generateCharacter( + nodeId: CharacterWorkflowNode['id'], + options: GenerateCharacterOptions, + ) { + ensurePositiveInteger(options.spriteWidth, 'spriteWidth') + ensurePositiveInteger(options.spriteHeight, 'spriteHeight') + return submitGeneration(nodeId, 'character_candidates', (run, node) => { + if (node.type !== 'character') throw new Error('目标节点不是 Character') + if (node.phase !== 'configuring_character') throw new Error('角色节点当前不能开始生成') + const input: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: node.input.prompt, + referenceMedia: node.input.referenceMedia, + ...options, + } + return input + }) + } + + function confirmCharacter(nodeId: CharacterWorkflowNode['id'], selectedImageUrl: string) { + ensureRunning() + const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'character') throw new Error('目标节点不是 Character') + if (node.status !== 'active' || node.phase !== 'selecting_character') { + throw new Error('角色节点当前不能确认候选图') + } + return unlockReadyNodes({ + ...run, + nodes: run.nodes.map((item) => + item.id === node.id + ? { ...node, selectedImageUrl: imageUrl, phase: 'completed', status: 'passed' } + : item, + ), + }) + }), + ) + } + + function generateActionFrame(nodeId: ActionWorkflowNode['id'], options: GenerateActionOptions) { + const characterId = nonEmpty(options.characterId, 'characterId') + return submitGeneration(nodeId, 'action_frame_candidates', (run, node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.phase !== 'configuring_action') throw new Error('Action 节点当前不能生成首帧') + const input: FirstFrameGenerationInput = { + type: 'first_frame', + projectId: run.projectId, + characterId, + outfitId: node.input.outfitId, + actionType: node.input.type, + prompt: node.input.prompt, + referenceMedia: options.referenceMedia, + } + return input + }) + } + + function confirmActionFrame(nodeId: ActionWorkflowNode['id'], selectedFirstFrameUrl: string) { + ensureRunning() + const imageUrl = nonEmpty(selectedFirstFrameUrl, 'selectedFirstFrameUrl') + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.status !== 'active' || node.phase !== 'selecting_action_frame') { + throw new Error('Action 节点当前不能确认首帧') + } + return replaceNode(run, { ...node, selectedFirstFrameUrl: imageUrl }) + }), + ) + } + + function generateAnimation(nodeId: ActionWorkflowNode['id'], options: GenerateActionOptions) { + const characterId = nonEmpty(options.characterId, 'characterId') + return submitGeneration(nodeId, 'animation', (run, node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.phase !== 'selecting_action_frame' || !node.selectedFirstFrameUrl) { + throw new Error('Action 节点尚未确认首帧') + } + const input: CompleteAnimationGenerationInput = { + type: 'complete_animation', + projectId: run.projectId, + characterId, + outfitId: node.input.outfitId, + actionType: node.input.type, + firstFrameUrl: node.selectedFirstFrameUrl, + prompt: node.input.prompt, + referenceMedia: options.referenceMedia, + } + return input + }) + } + + function approveAction(nodeId: ActionWorkflowNode['id']) { + ensureRunning() + return persist((run) => + updateNode(run, nodeId, (node) => { + if (node.type !== 'action') throw new Error('目标节点不是 Action') + if (node.status !== 'active' || node.phase !== 'reviewing_animation') { + throw new Error('Action 节点当前不能通过审核') + } + return unlockReadyNodes( + replaceNode(run, { ...node, status: 'passed', phase: 'completed', error: null }), + ) + }), + ) + } + + function submitGeneration( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + ): Promise { + ensureRunning() + const key = `${nodeId}:${role}` + const active = submissions.get(key) + if (active) return active + + const expectedEpoch = nodeEpoch(nodeId) + const submission = performGenerationSubmission( + nodeId, + role, + expectedEpoch, + createInput, + ).finally(() => { + if (submissions.get(key) === submission) submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performGenerationSubmission( + nodeId: WorkflowNode['id'], + role: WorkflowGenerationRole, + expectedEpoch: number, + createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + ): Promise { + const before = requireWorkflow() + const node = findNode(before, nodeId) + assertNodeCanRun(before, node) + const key = `${nodeId}:${role}` + const existing = node.generations.find((item) => item.role === role) + if (existing) { + await watchGeneration(node.id, existing.taskId) + return snapshot() + } + + const pendingAttachment = unattachedGenerations.get(key) + if (pendingAttachment?.expectedEpoch === expectedEpoch) { + return attachGeneration(pendingAttachment) + } + if (pendingAttachment) unattachedGenerations.delete(key) + + const generation = await generationApis.create(createInput(before, node)) + if (generation.projectId !== before.projectId) { + throw new Error('Generation 与 WorkflowRun 不属于同一项目') + } + // 重做发生在请求等待期间时,任务可以留在后端,但绝不能再挂回新的节点执行线。 + if (nodeEpoch(nodeId) !== expectedEpoch) return snapshot() + + const attachment = { nodeId, role, expectedEpoch, generation } + unattachedGenerations.set(key, attachment) + return attachGeneration(attachment) + } + + async function attachGeneration({ + nodeId, + role, + expectedEpoch, + generation, + }: PendingGenerationAttachment): Promise { + const key = `${nodeId}:${role}` + if (nodeEpoch(nodeId) !== expectedEpoch) { + if (unattachedGenerations.get(key)?.generation.id === generation.id) { + unattachedGenerations.delete(key) + } + return snapshot() + } + const attached = await persist((latest) => { + if (nodeEpoch(nodeId) !== expectedEpoch) return latest + const latestNode = findNode(latest, nodeId) + if (latestNode.generations.some((item) => item.role === role)) return latest + assertNodeCanRun(latest, latestNode) + return replaceNode(latest, { + ...latestNode, + phase: phaseForRunningRole(role), + generations: [...latestNode.generations, { taskId: generation.id, role }], + error: null, + }) + }) + const attachedReference = findNode(attached, nodeId).generations.find( + (item) => item.role === role, + ) + if (unattachedGenerations.get(key)?.generation.id === generation.id) { + unattachedGenerations.delete(key) + } + if (attachedReference?.taskId !== generation.id) { + return attached + } + + if (generation.status === 'completed' || generation.status === 'failed') { + return applyGenerationResult({ nodeId, taskId: generation.id, generation }) + } + await watchGeneration(nodeId, generation.id) + return snapshot() + } + + async function watchGeneration(nodeId: WorkflowNode['id'], taskId: Generation['id']) { + if (interrupted) return + const key = subscriptionKey(nodeId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { nodeId, taskId, stop: () => undefined }) + try { + const stop = generationApis.subscribe(requireWorkflow().projectId, taskId, (event) => { + if (event.taskId !== taskId || event.status === 'pending' || event.status === 'running') { + return + } + void settleGeneration(nodeId, taskId, event).catch((cause: unknown) => { + onAsyncError(asError(cause)) + }) + }) + const registered = subscriptions.get(key) + if (registered) subscriptions.set(key, { ...registered, stop }) + else stop() + + // 先订阅再查询,关闭“GET 看到运行中,订阅前任务已结束”的丢事件窗口。 + const latest = await generationApis.get(requireWorkflow().projectId, taskId) + if (latest.status === 'completed' || latest.status === 'failed') { + await settleGeneration(nodeId, taskId, latest) + } + } catch (cause) { + stopSubscription(key) + throw cause + } + } + + function settleGeneration( + nodeId: WorkflowNode['id'], + taskId: Generation['id'], + generation: Generation | GenerationEvent, + ): Promise { + if (interrupted) return Promise.resolve(snapshot()) + const key = subscriptionKey(nodeId, taskId) + const active = settlements.get(key) + if (active) return active + + const settlement = performSettlement(nodeId, taskId, generation).finally(() => { + if (settlements.get(key) === settlement) settlements.delete(key) + stopSubscription(key) + }) + settlements.set(key, settlement) + return settlement + } + + async function performSettlement( + nodeId: WorkflowNode['id'], + taskId: Generation['id'], + generation: Generation | GenerationEvent, + ) { + const normalized: Generation = + 'id' in generation + ? generation + : { + id: generation.taskId, + projectId: requireWorkflow().projectId, + type: generation.type, + status: generation.status, + result: generation.result, + error: generation.error, + } + return applyGenerationResult({ nodeId, taskId, generation: normalized }) + } + + function applyGenerationResult({ + nodeId, + taskId, + generation, + }: ApplyGenerationResultInput): Promise { + if (interrupted) return Promise.resolve(snapshot()) + return persist((run) => { + if (generation.id !== taskId || generation.projectId !== run.projectId) return run + const node = findNode(run, nodeId) + const reference = node.generations.find((item) => item.taskId === taskId) + if (!reference || node.status !== 'active') return run + // 一个 Action 会先后保留首帧和动画任务引用;只允许当前 phase 对应的任务推进。 + // 这样刷新恢复不会让已经完成的首帧任务把动画阶段倒退回首帧选择。 + if (node.phase !== phaseForRunningRole(reference.role)) return run + if (generation.status === 'pending' || generation.status === 'running') return run + if (generation.status === 'failed') { + return replaceNode(run, { + ...node, + status: 'failed', + error: generation.error?.trim() || '生成任务失败', + }) + } + return applyCompletedGeneration(run, node, reference, generation) + }) + } + + function applyCompletedGeneration( + run: WorkflowRun, + node: WorkflowNode, + reference: WorkflowGenerationRef, + generation: Generation, + ): WorkflowRun { + if (reference.role === 'character_candidates') { + if ( + node.type !== 'character' || + generation.type !== 'character_template' || + generation.result?.type !== 'character_template' || + generation.result.images.length === 0 + ) { + return failNode(run, node, '角色候选图结果格式无效') + } + return replaceNode(run, { ...node, phase: 'selecting_character', error: null }) + } + + if (reference.role === 'action_frame_candidates') { + if ( + node.type !== 'action' || + generation.type !== 'first_frame' || + generation.result?.type !== 'first_frame' || + !generation.result.image.url + ) { + return failNode(run, node, '动作首帧结果格式无效') + } + return replaceNode(run, { ...node, phase: 'selecting_action_frame', error: null }) + } + + if ( + node.type !== 'action' || + generation.type !== 'complete_animation' || + generation.result?.type !== 'complete_animation' + ) { + return failNode(run, node, '完整动画结果格式无效') + } + if (generation.result.frames.length !== COMPLETE_ANIMATION_FRAME_COUNT) { + return failNode( + run, + node, + `完整动画应为 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际为 ${generation.result.frames.length} 帧`, + ) + } + return replaceNode(run, { ...node, phase: 'reviewing_animation', error: null }) + } + + async function resume(): Promise { + interrupted = false + for (const attachment of [...unattachedGenerations.values()]) { + await attachGeneration(attachment) + } + const run = requireWorkflow() + const tasks = run.nodes.flatMap((node) => { + if (node.status !== 'active' || !isGeneratingPhase(node)) return [] + const role = roleForRunningPhase(node.phase) + const reference = node.generations.find((item) => item.role === role) + return reference ? [{ nodeId: node.id, taskId: reference.taskId }] : [] + }) + await Promise.all(tasks.map((task) => watchGeneration(task.nodeId, task.taskId))) + return snapshot() + } + + async function interrupt(): Promise { + interrupted = true + stopAllSubscriptions() + return snapshot() + } + + async function restartFromNode(nodeId: WorkflowNode['id']): Promise { + const before = requireWorkflow() + findNode(before, nodeId) + const affectedIds = collectDescendantIds(before.nodes, nodeId) + + const restarted = await persist((run) => { + const resetNodes = run.nodes.map((node) => + affectedIds.has(node.id) ? resetNode(node) : node, + ) + return { ...run, nodes: normalizeAvailability(resetNodes) } + }) + for (const affectedId of affectedIds) { + nodeEpochs.set(affectedId, nodeEpoch(affectedId) + 1) + for (const [key] of submissions) { + if (key.startsWith(`${affectedId}:`)) submissions.delete(key) + } + for (const [key] of unattachedGenerations) { + if (key.startsWith(`${affectedId}:`)) unattachedGenerations.delete(key) + } + } + // 不依赖重做前快照里的 taskId:引用保存与重做交错时,订阅可能刚刚才建立。 + for (const [key, subscription] of subscriptions) { + if (affectedIds.has(subscription.nodeId)) stopSubscription(key) + } + interrupted = false + return restarted + } + + async function getGeneration(nodeId: WorkflowNode['id'], role: WorkflowGenerationRole) { + const run = requireWorkflow() + const reference = findNode(run, nodeId).generations.find((item) => item.role === role) + return reference ? generationApis.get(run.projectId, reference.taskId) : null + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 释放传输连接失败不能反向改变已经持久化的 WorkflowRun。 + } + } + + function stopAllSubscriptions() { + for (const key of [...subscriptions.keys()]) stopSubscription(key) + } + + function dispose() { + interrupted = true + stopAllSubscriptions() + } + + function nodeEpoch(nodeId: WorkflowNode['id']) { + return nodeEpochs.get(nodeId) ?? 0 + } + + return { + create, + getWorkflow, + addAction, + generateCharacter, + confirmCharacter, + generateActionFrame, + confirmActionFrame, + generateAnimation, + approveAction, + resume, + interrupt, + restartFromNode, + applyGenerationResult, + getGeneration, + dispose, + } +} + +function updateNode( + run: WorkflowRun, + nodeId: WorkflowNode['id'], + update: (node: WorkflowNode) => WorkflowRun, +) { + return update(findNode(run, nodeId)) +} + +function findNode(run: WorkflowRun, nodeId: WorkflowNode['id']): WorkflowNode { + const node = run.nodes.find((item) => item.id === nodeId) + if (!node) throw new Error(`WorkflowNode 不存在:${nodeId}`) + return node +} + +function replaceNode(run: WorkflowRun, replacement: WorkflowNode): WorkflowRun { + return { + ...run, + nodes: run.nodes.map((node) => (node.id === replacement.id ? replacement : node)), + } +} + +function failNode(run: WorkflowRun, node: WorkflowNode, error: string): WorkflowRun { + return replaceNode(run, { ...node, status: 'failed', error }) +} + +function unlockReadyNodes(run: WorkflowRun): WorkflowRun { + return { + ...run, + nodes: run.nodes.map((node) => + node.status === 'locked' && + node.dependsOnNodeIds.every((dependencyId) => isPassed(run.nodes, dependencyId)) + ? { ...node, status: 'active' } + : node, + ), + } +} + +function normalizeAvailability(nodes: readonly WorkflowNode[]): WorkflowNode[] { + return nodes.map((node) => { + if (node.status === 'passed' || node.status === 'failed') return structuredClone(node) + const available = node.dependsOnNodeIds.every((dependencyId) => isPassed(nodes, dependencyId)) + return { ...structuredClone(node), status: available ? 'active' : 'locked' } + }) +} + +function isPassed(nodes: readonly WorkflowNode[], nodeId: string) { + return nodes.find((node) => node.id === nodeId)?.status === 'passed' +} + +function assertDependenciesExist(nodes: readonly WorkflowNode[], dependencyIds: readonly string[]) { + const knownIds = new Set(nodes.map((node) => node.id)) + const unknownId = dependencyIds.find((id) => !knownIds.has(id)) + if (unknownId) throw new Error(`依赖节点不存在:${unknownId}`) + if (new Set(dependencyIds).size !== dependencyIds.length) throw new Error('依赖节点不能重复') +} + +function assertNodeCanRun(run: WorkflowRun, node: WorkflowNode) { + if (node.status !== 'active') throw new Error('目标节点当前不可执行') + if (!node.dependsOnNodeIds.every((id) => isPassed(run.nodes, id))) { + throw new Error('目标节点的前置依赖尚未完成') + } +} + +function phaseForRunningRole(role: WorkflowGenerationRole): WorkflowNode['phase'] { + if (role === 'character_candidates') return 'generating_character_candidates' + if (role === 'action_frame_candidates') return 'generating_action_candidates' + return 'generating_animation' +} + +function roleForRunningPhase(phase: WorkflowNode['phase']): WorkflowGenerationRole { + if (phase === 'generating_character_candidates') return 'character_candidates' + if (phase === 'generating_action_candidates') return 'action_frame_candidates' + if (phase === 'generating_animation') return 'animation' + throw new Error(`当前 phase 不是生成阶段:${phase}`) +} + +function isGeneratingPhase(node: WorkflowNode) { + return ( + node.phase === 'generating_character_candidates' || + node.phase === 'generating_action_candidates' || + node.phase === 'generating_animation' + ) +} + +function collectDescendantIds(nodes: readonly WorkflowNode[], rootId: string) { + const affected = new Set([rootId]) + let changed = true + while (changed) { + changed = false + for (const node of nodes) { + if (affected.has(node.id)) continue + if (node.dependsOnNodeIds.some((id) => affected.has(id))) { + affected.add(node.id) + changed = true + } + } + } + return affected +} + +function resetNode(node: WorkflowNode): WorkflowNode { + if (node.type === 'character') { + return { + ...node, + status: 'locked', + phase: 'configuring_character', + generations: [], + error: null, + selectedImageUrl: null, + } + } + return { + ...node, + status: 'locked', + phase: 'configuring_action', + generations: [], + error: null, + selectedFirstFrameUrl: null, + } +} + +function subscriptionKey(nodeId: string, taskId: string) { + return `${nodeId}:${taskId}` +} + +function nonEmpty(value: string, field: string) { + const normalized = value.trim() + if (!normalized) throw new Error(`${field} 不能为空`) + return normalized +} + +function ensurePositiveInteger(value: number, field: string) { + if (!Number.isInteger(value) || value <= 0) throw new Error(`${field} 必须是正整数`) +} + +function createBrowserSafeId() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} + +function asError(cause: unknown) { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index b2ae950..d07245c 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,56 +1,9 @@ -import type { CreateWorkflowRunInput, WorkflowNode, WorkflowRun } from '@/entities' - -/** 更新工作流图中某个节点的业务数据。 */ -export interface UpdateWorkflowNodeInput { - nodeId: WorkflowNode['id'] - data: unknown -} - -/** 从指定节点重做;旧结果会被覆盖,不创建 Revision。 */ -export interface RestartWorkflowFromNodeInput { - nodeId: WorkflowNode['id'] -} - -/** 把某次服务端调用的结果写回目标节点。 */ -export interface ApplyServerResultInput { - nodeId: WorkflowNode['id'] - /** 必须仍是目标节点当前关联的任务,防止重做前的晚到结果覆盖新结果。 */ - taskId: string - result: unknown -} - -/** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一张节点图:手动模式由用户逐个推进,Quick Start 自动连续推进。 - * - * 节点和边由前端管理;服务端提供生成能力,并原样持久化 WorkflowRun.nodes。 - * 节点能否推进由 dependsOnNodeIds 指向的前置节点状态决定,不依赖数组位置。 - */ -export interface WorkflowController { - /** 初始化一条节点图。 */ - create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程。 */ - getWorkflow(): WorkflowRun - - /** 推进指定节点;无依赖关系的多个 Action 节点可以并行。 */ - advanceNode(nodeId: WorkflowNode['id']): Promise - - /** 连续推进所有当前可用节点到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定节点的数据;页面不绕过 Controller 直接改流程状态。 */ - updateNode(input: UpdateWorkflowNodeInput): Promise - - /** - * 把服务端返回的结果写回目标节点。 - * taskId 已不再属于目标节点时丢弃结果,避免旧请求污染重做后的状态。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** 从指定节点重做并覆盖其旧结果;后端不提供 Revision 历史。 */ - restartFromNode(input: RestartWorkflowFromNodeInput): Promise - - /** 用户主动停止自动推进;已完成节点保留,不等于失败或完成。 */ - interrupt(): Promise -} +export { createWorkflowController } from './controller' +export type { + AddActionInput, + ApplyGenerationResultInput, + CreateWorkflowControllerOptions, + GenerateActionOptions, + GenerateCharacterOptions, + WorkflowController, +} from './controller'