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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions frontend/src/entities/generation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -102,7 +102,7 @@ export type GenerationResultFor<T extends GenerationInput> =
* 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。
*
* TType 在调用边界已知时保留精确类型;按 ID 恢复时用默认值,等运行时解析后再收窄。
* 完成不代表工作流节点已通过,节点状态由 WorkflowStep 自己判定。
* 完成不代表工作流节点已通过,节点状态由 WorkflowNode 自己判定。
*/
export interface Generation<TType extends GenerationType = GenerationType> {
id: string
Expand Down
28 changes: 15 additions & 13 deletions frontend/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export { characterApis } from './character'
/* 动作模板 —— 能跨角色复用的配方 */
export type { ActionTemplate, ActionTemplateApis } from './action-template'

/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */
/* 生成 —— 业务数据,不是「调用生成能力」 */
export type {
CharacterTemplateGenerationInput,
CharacterTemplateGenerationResult,
Expand All @@ -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'
28 changes: 28 additions & 0 deletions frontend/src/entities/workflow-run/README.md
Original file line number Diff line number Diff line change
@@ -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 数据测试。
204 changes: 204 additions & 0 deletions frontend/src/entities/workflow-run/api.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
Loading