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 的卡片流程:确认角色 → 生成动作 → 检查交付。 */}