From 8cf6b311eccfab69d5134ea60c3dd405d20c39dd Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:09:05 +0800 Subject: [PATCH] fix(workflow-run): persist explicit node graph --- frontend/src/entities/generation/index.ts | 6 +- frontend/src/entities/index.ts | 28 ++- frontend/src/entities/workflow-run/README.md | 28 +++ .../src/entities/workflow-run/api.test.ts | 204 ++++++++++++++++ frontend/src/entities/workflow-run/api.ts | 230 ++++++++++++++++++ .../src/entities/workflow-run/constants.ts | 27 ++ frontend/src/entities/workflow-run/index.ts | 210 ++++++---------- .../src/features/workflow-controller/index.ts | 63 ++--- frontend/src/pages/character-detail/index.tsx | 2 +- frontend/src/pages/home/index.tsx | 7 +- frontend/src/shared/README.md | 2 +- 11 files changed, 610 insertions(+), 197 deletions(-) create mode 100644 frontend/src/entities/workflow-run/README.md create mode 100644 frontend/src/entities/workflow-run/api.test.ts create mode 100644 frontend/src/entities/workflow-run/api.ts create mode 100644 frontend/src/entities/workflow-run/constants.ts diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d4..33f21313 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -10,8 +10,8 @@ import type { MediaReference } from '../media' */ /** - * 后端 GenerationTask.status,与 WorkflowRevision.generationStatus 不是一回事: - * 这里是单次生成任务的状态,那里是一个版本在生成阶段的汇总状态。 + * 后端 GenerationTask.status,与 WorkflowNode.status 不是一回事: + * 这里是单次生成任务的状态,那里是一个卡片的前端流程状态。 * pending 表示已提交但尚未执行。 */ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' @@ -102,7 +102,7 @@ export type GenerationResultFor = * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 * * TType 在调用边界已知时保留精确类型;按 ID 恢复时用默认值,等运行时解析后再收窄。 - * 完成不代表工作流节点已通过,节点状态由 WorkflowStep 自己判定。 + * 完成不代表工作流节点已通过,节点状态由 WorkflowNode 自己判定。 */ export interface Generation { id: string diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 79a3d212..0432e2e6 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -27,7 +27,7 @@ export { characterApis } from './character' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +/* 生成 —— 业务数据,不是「调用生成能力」 */ export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -49,19 +49,21 @@ export type { /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' -/* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +/* 工作流 —— 前端管理节点,后端只持久化完整 nodes 文档 */ +export { workflowRunApis } from './workflow-run' export type { + ActionWorkflowNode, + CharacterWorkflowNode, CreateWorkflowRunInput, - ExportStatus, - GenerationStatus, - WorkflowDriver, - WorkflowStep, - WorkflowStepStatus, - WorkflowStepType, - WorkflowRevision, - WorkflowRevisionStatus, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowNode, + WorkflowNodePhase, + WorkflowNodeStatus, + WorkflowNodeType, + WorkflowRunApis, + WorkflowRunStorageStatus, WorkflowRun, - WorkflowRunPurpose, - WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md new file mode 100644 index 00000000..589d6330 --- /dev/null +++ b/frontend/src/entities/workflow-run/README.md @@ -0,0 +1,28 @@ +# WorkflowRun + +本目录只保存工作流核心数据和后端持久化接口,不实现页面推进逻辑。 + +## 已确认的模型 + +- 前后端统一使用 `WorkflowNode`。原先前端的 Step 与后端的 Node 是同一概念,已经合并。 +- `WorkflowRun.nodes` 直接保存真实节点,不再使用 `root.steps` 或人为包装的根节点。 +- 一个节点与 Workflow Editor 中一张卡片一一对应;生成与选择是节点内部 phase,不拆成额外节点。 +- 节点通过 `dependsOnNodeIds` 保存直接前置依赖,因此边会与节点一起落库,不再依赖数组顺序猜测连线。 +- 多个 Action 节点可以依赖同一个角色节点;前置节点通过后即可并行,不互相阻塞。 +- Quick Start 与 Workflow Editor 是两种独立界面,但推进同一张节点图,核心数据不区分 `ai/manual driver`。 +- 后端不提供 Revision 历史。重做时覆盖旧结果,并用 `nodeId + taskId` 防止旧请求串线。 + +## 前后端边界 + +前端负责节点结构、依赖边、推进规则和状态变化;后端只把 `WorkflowRun.nodes` JSON 原样保存。 +HTTP 接口严格对应 `POST /workflow-runs`、`GET/PATCH/DELETE /workflow-runs/{id}`。 + +当前后端没有列表、按 Character 查询或订阅接口,因此前端也不虚构这些方法。所有持久化调用 +都是异步的。后端 CRUD service 尚未实现时,本模块只提供真实接口适配器,不宣称已经联通。 + +## 文件 + +- `constants.ts`:核心节点状态、类型和 phase。 +- `index.ts`:WorkflowRun、WorkflowNode 与 API 类型。 +- `api.ts`:后端 DTO 映射、节点图校验和 HTTP 适配。 +- `api.test.ts`:直接节点映射、边校验及并行 Action 数据测试。 diff --git a/frontend/src/entities/workflow-run/api.test.ts b/frontend/src/entities/workflow-run/api.test.ts new file mode 100644 index 00000000..51bb1bc3 --- /dev/null +++ b/frontend/src/entities/workflow-run/api.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowNode } from './index' + +const nodes: WorkflowNode[] = [ + { + id: 'character-node', + type: 'character', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [{ taskId: '91', role: 'character_candidates' }], + error: null, + input: { prompt: '一个像素骑士', referenceMedia: [] }, + selectedImageUrl: 'https://cdn.windup.test/character.png', + }, + { + id: 'walk-node', + type: 'action', + status: 'active', + phase: 'generating_animation', + dependsOnNodeIds: ['character-node'], + generations: [{ taskId: '92', role: 'animation' }], + error: null, + input: { outfitId: 'outfit-1', name: '行走', type: 'walk', prompt: null, fps: 12 }, + selectedFirstFrameUrl: 'https://cdn.windup.test/walk-first.png', + }, + { + id: 'jump-node', + type: 'action', + status: 'active', + phase: 'generating_animation', + dependsOnNodeIds: ['character-node'], + generations: [{ taskId: '93', role: 'animation' }], + error: null, + input: { outfitId: 'outfit-1', name: '跳跃', type: 'jump', prompt: null, fps: 12 }, + selectedFirstFrameUrl: 'https://cdn.windup.test/jump-first.png', + }, +] + +const workflowRunDto = { + id: 17, + project_id: 42, + nodes, + status: 'active', + version: 3, +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.resetModules() +}) + +async function loadWorkflowRunApis(fetchFn: typeof fetch) { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', fetchFn) + return (await import('./api')).workflowRunApis +} + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + headers: { 'content-type': 'application/json' }, + }) +} + +describe('workflowRunApis', () => { + it('persists frontend nodes directly without a synthetic root node', async () => { + let request: Request | undefined + const apis = await loadWorkflowRunApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(workflowRunDto) + }) + + await expect(apis.create({ projectId: '42', nodes })).resolves.toEqual({ + id: '17', + projectId: '42', + version: 3, + storageStatus: 'active', + nodes, + }) + expect(request?.url).toBe('https://api.windup.test/workflow-runs') + expect(request?.method).toBe('POST') + await expect(request?.json()).resolves.toEqual({ project_id: 42, nodes }) + }) + + it('gets a run through the backend resource path', async () => { + let requestUrl = '' + const apis = await loadWorkflowRunApis(async (input) => { + requestUrl = String(input) + return jsonResponse(workflowRunDto) + }) + await apis.get('17') + expect(requestUrl).toBe('https://api.windup.test/workflow-runs/17') + }) + + it('patches the complete node graph and uses the returned version', async () => { + let request: Request | undefined + const apis = await loadWorkflowRunApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse({ ...workflowRunDto, version: 4 }) + }) + const updated = await apis.update({ + id: '17', + projectId: '42', + version: 3, + storageStatus: 'active', + nodes, + }) + expect(request?.method).toBe('PATCH') + await expect(request?.json()).resolves.toEqual({ nodes, status: 'active' }) + expect(updated.version).toBe(4) + }) + + it('soft deletes through the backend DELETE endpoint', async () => { + let request: Request | undefined + const apis = await loadWorkflowRunApis(async (input, init) => { + request = new Request(input, init) + return jsonResponse(null) + }) + await expect(apis.remove('17')).resolves.toBeUndefined() + expect(request?.url).toBe('https://api.windup.test/workflow-runs/17') + expect(request?.method).toBe('DELETE') + }) + + it('rejects a node without an explicit dependency list', async () => { + const [{ dependsOnNodeIds: _omitted, ...invalidNode }, ...rest] = nodes + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [invalidNode, ...rest] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a dependency that points outside the persisted graph', async () => { + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ + ...workflowRunDto, + nodes: nodes.map((node) => + node.id === 'walk-node' ? { ...node, dependsOnNodeIds: ['missing-node'] } : node, + ), + }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a cyclic node graph', async () => { + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ + ...workflowRunDto, + nodes: nodes.map((node) => + node.id === 'character-node' ? { ...node, dependsOnNodeIds: ['walk-node'] } : node, + ), + }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('accepts an action-only graph for adding an action to an existing character', async () => { + const actionOnlyDto = { + ...workflowRunDto, + nodes: [{ ...nodes[1], dependsOnNodeIds: [] }], + } + const apis = await loadWorkflowRunApis(async () => jsonResponse(actionOnlyDto)) + await expect(apis.get('17')).resolves.toMatchObject({ nodes: actionOnlyDto.nodes }) + }) + + it('rejects completed nodes that lost their selected asset', async () => { + const completedActionWithoutSelection = { + ...nodes[1], + status: 'passed' as const, + phase: 'completed' as const, + selectedFirstFrameUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [nodes[0], completedActionWithoutSelection] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a completed character node that lost its selected image', async () => { + const completedCharacterWithoutSelection = { + ...nodes[0], + selectedImageUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [completedCharacterWithoutSelection] }), + ) + 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 new file mode 100644 index 00000000..2e64314b --- /dev/null +++ b/frontend/src/entities/workflow-run/api.ts @@ -0,0 +1,230 @@ +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { + ActionWorkflowNode, + CharacterWorkflowNode, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from './index' +import { + WORKFLOW_GENERATION_ROLES, + WORKFLOW_NODE_PHASES, + WORKFLOW_NODE_STATUSES, + WORKFLOW_RUN_STORAGE_STATUSES, +} from './constants' + +interface WorkflowRunDto { + id: number + project_id: number + nodes: unknown[] + status: string + version: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function isGenerationRef(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.taskId === 'string' && + value.taskId.length > 0 && + isMember(value.role, WORKFLOW_GENERATION_ROLES) + ) +} + +function hasValidCommonNodeFields(value: Record): boolean { + if ( + typeof value.id !== 'string' || + value.id.length === 0 || + !isMember(value.status, WORKFLOW_NODE_STATUSES) || + !isMember(value.phase, WORKFLOW_NODE_PHASES) || + !Array.isArray(value.dependsOnNodeIds) || + !value.dependsOnNodeIds.every((id) => typeof id === 'string' && id.length > 0) || + new Set(value.dependsOnNodeIds).size !== value.dependsOnNodeIds.length || + !Array.isArray(value.generations) || + !value.generations.every(isGenerationRef) || + !isNullableString(value.error) + ) { + return false + } + return value.status === 'failed' + ? typeof value.error === 'string' && value.error.trim().length > 0 + : value.error === null +} + +function isCharacterNode(value: unknown): value is CharacterWorkflowNode { + if (!isRecord(value) || value.type !== 'character' || !hasValidCommonNodeFields(value)) { + return false + } + if ( + ![ + 'configuring_character', + 'generating_character_candidates', + 'selecting_character', + 'completed', + ].includes(String(value.phase)) || + !isRecord(value.input) + ) { + return false + } + return ( + typeof value.input.prompt === 'string' && + Array.isArray(value.input.referenceMedia) && + value.input.referenceMedia.every((item) => typeof item === 'string') && + isNullableString(value.selectedImageUrl) && + (value.phase !== 'completed' || + (typeof value.selectedImageUrl === 'string' && value.selectedImageUrl.length > 0)) + ) +} + +function isActionNode(value: unknown): value is ActionWorkflowNode { + if (!isRecord(value) || value.type !== 'action' || !hasValidCommonNodeFields(value)) return false + if ( + ![ + 'configuring_action', + 'generating_action_candidates', + 'selecting_action_frame', + 'generating_animation', + 'reviewing_animation', + 'completed', + ].includes(String(value.phase)) || + !isRecord(value.input) + ) { + return false + } + return ( + typeof value.input.outfitId === 'string' && + value.input.outfitId.length > 0 && + typeof value.input.name === 'string' && + value.input.name.length > 0 && + typeof value.input.type === 'string' && + value.input.type.length > 0 && + isNullableString(value.input.prompt) && + typeof value.input.fps === 'number' && + Number.isFinite(value.input.fps) && + value.input.fps > 0 && + isNullableString(value.selectedFirstFrameUrl) && + (value.phase !== 'completed' || + (typeof value.selectedFirstFrameUrl === 'string' && value.selectedFirstFrameUrl.length > 0)) + ) +} + +function isWorkflowNode(value: unknown): value is WorkflowNode { + return isCharacterNode(value) || isActionNode(value) +} + +function isAcyclicNodeGraph(nodes: readonly WorkflowNode[]): boolean { + const nodeIds = new Set(nodes.map((node) => node.id)) + if (nodeIds.size !== nodes.length) return false + if ( + nodes.some( + (node) => + node.dependsOnNodeIds.includes(node.id) || + node.dependsOnNodeIds.some((dependencyId) => !nodeIds.has(dependencyId)), + ) + ) { + return false + } + + const dependencies = new Map(nodes.map((node) => [node.id, node.dependsOnNodeIds])) + const visiting = new Set() + const visited = new Set() + + function visit(nodeId: string): boolean { + if (visited.has(nodeId)) return true + if (visiting.has(nodeId)) return false + visiting.add(nodeId) + for (const dependencyId of dependencies.get(nodeId) ?? []) { + if (!visit(dependencyId)) return false + } + visiting.delete(nodeId) + visited.add(nodeId) + return true + } + + return nodes.every((node) => visit(node.id)) +} + +function isWorkflowNodeGraph(value: unknown): value is WorkflowNode[] { + return Array.isArray(value) && value.every(isWorkflowNode) && isAcyclicNodeGraph(value) +} + +function invalidResponse(data: unknown): never { + throw new ApiError('后端 WorkflowRun 响应格式无效', { + kind: 'invalid-response', + data, + }) +} + +function mapWorkflowRun(dto: WorkflowRunDto): WorkflowRun { + if ( + !isRecord(dto) || + !Number.isSafeInteger(dto.id) || + dto.id <= 0 || + !Number.isSafeInteger(dto.project_id) || + dto.project_id <= 0 || + !isWorkflowNodeGraph(dto.nodes) || + !isMember(dto.status, WORKFLOW_RUN_STORAGE_STATUSES) || + !Number.isSafeInteger(dto.version) || + dto.version < 1 + ) { + return invalidResponse(dto) + } + return { + id: String(dto.id), + projectId: String(dto.project_id), + version: dto.version, + storageStatus: dto.status, + nodes: structuredClone(dto.nodes), + } +} + +function toBackendId(value: string, field: string): number { + const parsed = Number(value) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + throw new TypeError(`${field} 必须是正整数 ID`) +} + +function getApiClient() { + return createApiClient({ getAccessToken: getApiAccessToken }) +} + +/** 精确对应后端已公开的 CRUD;不声明尚未提供的列表或按 Character 查询。 */ +export const workflowRunApis: WorkflowRunApis = { + async create(input) { + return mapWorkflowRun( + await getApiClient().request('/workflow-runs', { + method: 'POST', + json: { project_id: toBackendId(input.projectId, 'projectId'), nodes: input.nodes }, + }), + ) + }, + async get(id) { + return mapWorkflowRun( + await getApiClient().request(`/workflow-runs/${encodeURIComponent(id)}`), + ) + }, + async update(run) { + return mapWorkflowRun( + await getApiClient().request(`/workflow-runs/${encodeURIComponent(run.id)}`, { + method: 'PATCH', + json: { nodes: run.nodes, status: run.storageStatus }, + }), + ) + }, + async remove(id) { + await getApiClient().request(`/workflow-runs/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) + }, +} diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 00000000..e8ba9c9c --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,27 @@ +/** WorkflowRun 使用的稳定业务词汇。 */ + +/** 后端资源状态只表达是否被软删除,不等同于前端节点状态。 */ +export const WORKFLOW_RUN_STORAGE_STATUSES = ['active', 'soft_deleted'] as const + +/** WorkflowNode 与 Workflow Editor 中用户看到的卡片一一对应。 */ +export const WORKFLOW_NODE_TYPES = ['character', 'action'] as const +export const WORKFLOW_NODE_STATUSES = ['locked', 'active', 'passed', 'failed'] as const + +/** phase 描述节点内部状态,不把“生成”和“选择”拆成额外节点。 */ +export const WORKFLOW_NODE_PHASES = [ + 'configuring_character', + 'generating_character_candidates', + 'selecting_character', + 'configuring_action', + 'generating_action_candidates', + 'selecting_action_frame', + 'generating_animation', + 'reviewing_animation', + 'completed', +] as const + +export const WORKFLOW_GENERATION_ROLES = [ + 'character_candidates', + 'action_frame_candidates', + 'animation', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b5..7b51b324 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,157 +1,95 @@ +import type { ActionType } from '../character' import type { Generation } from '../generation' +import type { MediaReference } from '../media' +import { + WORKFLOW_GENERATION_ROLES, + WORKFLOW_NODE_PHASES, + WORKFLOW_NODE_STATUSES, + WORKFLOW_NODE_TYPES, + WORKFLOW_RUN_STORAGE_STATUSES, +} from './constants' -/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export type WorkflowRunStorageStatus = (typeof WORKFLOW_RUN_STORAGE_STATUSES)[number] +export type WorkflowNodeType = (typeof WORKFLOW_NODE_TYPES)[number] +export type WorkflowNodeStatus = (typeof WORKFLOW_NODE_STATUSES)[number] +export type WorkflowNodePhase = (typeof WORKFLOW_NODE_PHASES)[number] +export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] -/** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' - -/** - * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 - */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const - -/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ -export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] - -/** - * 步骤的可用性和执行结果;不直接复用后端任务状态。 - * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 - */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' - -/** - * 单个版本的生命周期。 - * abandoned 表示停止沿用但仍保留为历史。 - */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' - -/** - * 整次流程的汇总状态。 - * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 - */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' - -/** - * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 - * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。 - */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' - -/** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' +/** 一个节点对后端 GenerationTask 的引用;节点可关联零个、一个或多个任务。 */ +export interface WorkflowGenerationRef { + taskId: Generation['id'] + role: WorkflowGenerationRole +} -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { - /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ +interface WorkflowNodeBase { id: string - type: WorkflowStepType - status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown + type: WorkflowNodeType + status: WorkflowNodeStatus + phase: WorkflowNodePhase /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 - * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 - * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 + * 本节点的直接前置节点 ID。空数组表示图的入口;多个 ID 表示汇合依赖。 + * 边随节点一起存入后端 nodes JSON,不能再用数组位置猜测连线。 */ - taskId: Generation['id'] | null - /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ - referenceStepIds: string[] + dependsOnNodeIds: string[] + generations: WorkflowGenerationRef[] + error: string | null } -/** - * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 - * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 - */ -export interface WorkflowRevision { - id: string - /** 首次创建的版本没有来源,因此为 null。 */ - basedOnRevisionId: string | null - /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ - restartStepId: string | null - status: WorkflowRevisionStatus - /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 - */ - steps: WorkflowStep[] - generationStatus: GenerationStatus - exportStatus: ExportStatus - createdAt: string +export interface WorkflowCharacterInput { + prompt: string + referenceMedia: readonly MediaReference[] +} + +/** 角色节点内部完成资料填写、候选图生成和候选确认。 */ +export interface CharacterWorkflowNode extends WorkflowNodeBase { + type: 'character' + input: WorkflowCharacterInput + selectedImageUrl: string | null } +export interface WorkflowActionInput { + outfitId: string + name: string + type: ActionType + prompt: string | null + fps: number +} + +/** 一个 Action 对应一个节点;共同依赖同一节点的多个 Action 可以并行。 */ +export interface ActionWorkflowNode extends WorkflowNodeBase { + type: 'action' + input: WorkflowActionInput + selectedFirstFrameUrl: string | null +} + +/** 工作流图中的真实节点。前端和后端统一使用 node,不再保留 step 或假 root。 */ +export type WorkflowNode = CharacterWorkflowNode | ActionWorkflowNode + /** - * 一次由前端推进的页面流程。 - * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 - * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 + * 一次制作流程的持久化容器。Quick Start 与 Workflow Editor 只是不同界面; + * 两者读取和推进同一份节点图。 */ export interface WorkflowRun { id: string projectId: string - /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ - characterId: string | null - /** 已有角色加动作时的目标造型;新建角色时为 null。 */ - outfitId: string | null - purpose: WorkflowRunPurpose - driver: WorkflowDriver - status: WorkflowRunStatus - /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ - currentRevisionId: string - /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ - revisions: WorkflowRevision[] - /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ - prompt: string | null + /** 后端乐观版本号,每次 PATCH 后使用响应中的新值。 */ + version: number + /** 后端资源状态,仅表示正常或软删除。 */ + storageStatus: WorkflowRunStorageStatus + /** 真实节点图;节点间的边由 dependsOnNodeIds 表达。 */ + nodes: WorkflowNode[] } -/** 两种入口共享的创建字段。 */ -interface CreateWorkflowRunInputBase { +export interface CreateWorkflowRunInput { projectId: string - driver: WorkflowDriver - /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ - prompt?: string + nodes: WorkflowNode[] } -/** - * 创建 WorkflowRun 的输入。 - * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 - */ -export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & - ( - | { - purpose: 'create_character' - characterId?: never - outfitId?: never - characterTemplateUrl?: never - baseFrameUrls?: never - } - | { - purpose: 'add_action' - characterId: string - outfitId: string - characterTemplateUrl: string - baseFrameUrls: readonly string[] - } - ) +export interface WorkflowRunApis { + create(input: CreateWorkflowRunInput): Promise + get(id: WorkflowRun['id']): Promise + update(run: WorkflowRun): Promise + remove(id: WorkflowRun['id']): Promise +} + +export { workflowRunApis } from './api' diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce8792..b2ae950f 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,56 @@ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowStep, -} from '@/entities' +import type { CreateWorkflowRunInput, WorkflowNode, WorkflowRun } from '@/entities' -/** 更新当前 Revision 中某个步骤的业务数据。 */ -export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] +/** 更新工作流图中某个节点的业务数据。 */ +export interface UpdateWorkflowNodeInput { + nodeId: WorkflowNode['id'] data: unknown } -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] +/** 从指定节点重做;旧结果会被覆盖,不创建 Revision。 */ +export interface RestartWorkflowFromNodeInput { + nodeId: WorkflowNode['id'] } -/** 把某次服务端调用的结果写回目标步骤。 */ +/** 把某次服务端调用的结果写回目标节点。 */ export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] + nodeId: WorkflowNode['id'] + /** 必须仍是目标节点当前关联的任务,防止重做前的晚到结果覆盖新结果。 */ + taskId: string result: unknown } /** * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 + * 两套界面共享同一张节点图:手动模式由用户逐个推进,Quick Start 自动连续推进。 * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 + * 节点和边由前端管理;服务端提供生成能力,并原样持久化 WorkflowRun.nodes。 + * 节点能否推进由 dependsOnNodeIds 指向的前置节点状态决定,不依赖数组位置。 */ export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ + /** 初始化一条节点图。 */ create(input: CreateWorkflowRunInput): Promise - /** 读取当前维护的完整流程快照。 */ + /** 读取当前维护的完整流程。 */ getWorkflow(): WorkflowRun - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise + /** 推进指定节点;无依赖关系的多个 Action 节点可以并行。 */ + advanceNode(nodeId: WorkflowNode['id']): Promise - /** 连续推进到终点,Quick Start 使用。 */ + /** 连续推进所有当前可用节点到终点,Quick Start 使用。 */ runToCompletion(): Promise - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise + /** 更新指定节点的数据;页面不绕过 Controller 直接改流程状态。 */ + updateNode(input: UpdateWorkflowNodeInput): Promise /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 + * 把服务端返回的结果写回目标节点。 + * taskId 已不再属于目标节点时丢弃结果,避免旧请求污染重做后的状态。 */ applyServerResult(input: ApplyServerResultInput): Promise - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise + /** 从指定节点重做并覆盖其旧结果;后端不提供 Revision 历史。 */ + restartFromNode(input: RestartWorkflowFromNodeInput): Promise - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ + /** 用户主动停止自动推进;已完成节点保留,不等于失败或完成。 */ interrupt(): Promise } diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx index d3e28a87..ee4fccb1 100644 --- a/frontend/src/pages/character-detail/index.tsx +++ b/frontend/src/pages/character-detail/index.tsx @@ -287,7 +287,7 @@ function ActionList({ character, outfit }: { character: Character; outfit: Outfi type="button" aria-label={`重新生成${selectedAction.name}`} disabled - title="需要原 WorkflowRun 的版本与步骤上下文" + title="需要原 WorkflowRun 的步骤上下文" className="cursor-not-allowed rounded-full border border-[#d8dcd5] px-3 py-1.5 text-xs font-semibold text-[#959b94]" > 重新生成 diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 5e6c906e..4ae11e8a 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -28,12 +28,7 @@ export function HomePage() {

- {/* - 首屏用的三段式说法,是 entities/workflow-run 那八步 WORKFLOW_STEP_ORDER 的粗粒度概括: - 确认角色 = character-setup + character-template,生成动作 = first-frame + complete-animation, - 检查交付 = review + export;template-candidate 与 action-setup 是流程内部环节,首屏不提。 - 这份对应关系目前只写在这里,八步一变这段文案不会跟着变,改流程时要一并改。 - */} + {/* 首屏用三段式概括 WorkflowRun 的卡片流程:确认角色 → 生成动作 → 检查交付。 */}