From 49486133228e480ffe0f7033e5f13755eb92007a 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 1/3] 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 7ab91f0b..4125ea91 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -31,7 +31,7 @@ export { characterApis } from './character' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +/* 生成 —— 业务数据,不是「调用生成能力」 */ export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -53,19 +53,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 的卡片流程:确认角色 → 生成动作 → 检查交付。 */}
    Date: Fri, 7 Aug 2026 15:36:44 +0800 Subject: [PATCH 2/3] feat(workflow-controller): coordinate one workflow run --- frontend/src/entities/generation/index.ts | 3 + .../features/workflow-controller/README.md | 29 + .../workflow-controller/controller.test.ts | 638 ++++++++++++++ .../workflow-controller/controller.ts | 796 ++++++++++++++++++ .../src/features/workflow-controller/index.ts | 65 +- 5 files changed, 1475 insertions(+), 56 deletions(-) create mode 100644 frontend/src/features/workflow-controller/README.md create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.ts diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 33f21313..d6841b6f 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/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md new file mode 100644 index 00000000..5d779610 --- /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 00000000..97fd2809 --- /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 00000000..2a17e403 --- /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 b2ae950f..d07245c6 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' From f603a049ec742f563fa53eb21a84d089d8920bf8 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:33:59 +0800 Subject: [PATCH 3/3] refactor: align controller with 5-node model (action-first-frame + action-full-frame) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace template-candidate/action-generation with action-first-frame/action-full-frame - confirmCandidate → confirmFirstFrame - Update all state transitions and tests Co-Authored-By: Claude --- frontend/src/entities/constants.ts | 14 + frontend/src/entities/index.ts | 81 +- frontend/src/entities/workflow-run/README.md | 28 - .../src/entities/workflow-run/api.test.ts | 78 + frontend/src/entities/workflow-run/api.ts | 64 +- .../src/entities/workflow-run/constants.ts | 39 +- frontend/src/entities/workflow-run/index.ts | 201 ++- .../src/entities/workflow-run/store.test.ts | 330 +++++ frontend/src/entities/workflow-run/store.ts | 240 +++ .../action-generation-task.ts | 282 ++++ .../character-template-task.ts | 492 +++++++ .../workflow-controller/controller.test.ts | 905 ++++-------- .../workflow-controller/controller.ts | 1283 ++++++++-------- .../src/features/workflow-controller/index.ts | 5 +- .../store-invariants.test.ts | 88 ++ .../workflow-run.integration.test.ts | 103 ++ .../workflow-state.test.ts | 212 +++ .../workflow-controller/workflow-state.ts | 467 ++++++ frontend/src/pages/quick-start/index.test.tsx | 116 ++ frontend/src/pages/quick-start/index.tsx | 958 +++++++++++- .../src/pages/quick-start/service.test.ts | 166 +++ frontend/src/pages/quick-start/service.ts | 399 +++++ .../src/pages/workflow-editor/index.test.tsx | 419 ++++++ frontend/src/pages/workflow-editor/index.tsx | 515 ++++++- .../src/pages/workflow-editor/node-canvas.ts | 264 ++++ frontend/src/pages/workflow-editor/service.ts | 183 +++ .../workflow-editor/workflow-canvas.test.tsx | 195 +++ .../pages/workflow-editor/workflow-canvas.tsx | 393 +++++ .../pages/workflow-editor/workflow-editor.css | 1285 +++++++++++++++++ 29 files changed, 8283 insertions(+), 1522 deletions(-) create mode 100644 frontend/src/entities/constants.ts delete mode 100644 frontend/src/entities/workflow-run/README.md create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/entities/workflow-run/store.ts create mode 100644 frontend/src/features/workflow-controller/action-generation-task.ts create mode 100644 frontend/src/features/workflow-controller/character-template-task.ts create mode 100644 frontend/src/features/workflow-controller/store-invariants.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-run.integration.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.ts create mode 100644 frontend/src/pages/quick-start/index.test.tsx create mode 100644 frontend/src/pages/quick-start/service.test.ts create mode 100644 frontend/src/pages/quick-start/service.ts create mode 100644 frontend/src/pages/workflow-editor/index.test.tsx create mode 100644 frontend/src/pages/workflow-editor/node-canvas.ts create mode 100644 frontend/src/pages/workflow-editor/service.ts create mode 100644 frontend/src/pages/workflow-editor/workflow-canvas.test.tsx create mode 100644 frontend/src/pages/workflow-editor/workflow-canvas.tsx create mode 100644 frontend/src/pages/workflow-editor/workflow-editor.css diff --git a/frontend/src/entities/constants.ts b/frontend/src/entities/constants.ts new file mode 100644 index 00000000..62eafbdb --- /dev/null +++ b/frontend/src/entities/constants.ts @@ -0,0 +1,14 @@ +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 新建角色时的基础节点顺序;之后可并发追加 action-full-frame / review 成对节点。 */ +export const WORKFLOW_NODE_ORDER = [ + 'character-setup', + 'character-template', + 'action-first-frame', + 'action-full-frame', + 'review', +] as const diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 4125ea91..635c71cf 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,73 +1,92 @@ -/** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ +/** + * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 + * 外部只从这里使用实体契约与已经落地的实体能力。 + */ -/* 用户 —— 认证传输与稳定会话身份 */ -export { createUserApis, userApis } from './user' -export type { AuthTokens, CreateUserApisOptions, SendCodePurpose, User, UserApis } from './user' +/* 用户 —— 认证态与账户资料。 */ +export { createUserApis } from './user/api' +export type { CreateUserApisOptions } from './user/api' +export type { AuthTokens, User, UserApis } from './user' /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ -export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' -export { projectApis } from './project' +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { createProjectApis } from './project/api' export type { CharacterPerspective, CreateProjectInput, DirectionalMovement, Project, ProjectApis, - ProjectPageQuery, } from './project' /* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ export type { Action, + ActionKind, ActionType, + BaseFrame, Character, CharacterApis, + CharacterTemplateCandidate, + ConfirmCharacterTemplateInput, CreateCharacterInput, Frame, + FrameRootMotion, Outfit, } from './character' -export { characterApis } from './character' - -/* 动作模板 —— 能跨角色复用的配方 */ -export type { ActionTemplate, ActionTemplateApis } from './action-template' +export { createCharacterApis } from './character/api' /* 生成 —— 业务数据,不是「调用生成能力」 */ +export { CHARACTER_ACTION_FRAME_COUNT } from './generation' +export { createGenerationApis } from './generation/api' export type { - CharacterTemplateGenerationInput, - CharacterTemplateGenerationResult, - CompleteAnimationGenerationInput, - CompleteAnimationGenerationResult, - FirstFrameGenerationInput, - FirstFrameGenerationResult, - GeneratedImage, + CharacterActionFrame, + CharacterActionGenerationInput, + CharacterActionOutput, + CharacterImageGenerationInput, + CharacterImageOutput, Generation, GenerationApis, GenerationEvent, GenerationInput, GenerationResult, GenerationResultFor, + GenerationTaskStatus, GenerationType, - TaskStatus, } from './generation' /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ -export type { MediaReference } from './media' +export { createMediaApis } from './media/api' +export type { MediaApis, MediaCategory, MediaReference } from './media' + +/* Playtest 核验 —— 每个动作当前最新的核验结论,不形成历史版本 */ +export { createPlaytestInspectionApis } from './playtest-inspection/api' +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from './playtest-inspection' -/* 工作流 —— 前端管理节点,后端只持久化完整 nodes 文档 */ -export { workflowRunApis } from './workflow-run' +/* 工作流 —— 节点与运行状态都由前端管理 */ +export { createWorkflowRunStore, WORKFLOW_NODE_ORDER } from './workflow-run' export type { - ActionWorkflowNode, - CharacterWorkflowNode, + CharacterSetupNodeInput, + CharacterSetupWorkflowNode, + CharacterTemplateWorkflowNode, + ActionFirstFrameWorkflowNode, + ActionFullFrameWorkflowNode, CreateWorkflowRunInput, - WorkflowActionInput, - WorkflowCharacterInput, - WorkflowGenerationRef, - WorkflowGenerationRole, + ExportStatus, + GenerationStatus, WorkflowNode, - WorkflowNodePhase, WorkflowNodeStatus, WorkflowNodeType, - WorkflowRunApis, - WorkflowRunStorageStatus, WorkflowRun, + WorkflowRunStore, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowRevision, + CreateWorkflowRunStoreOptions, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md deleted file mode 100644 index 589d6330..00000000 --- a/frontend/src/entities/workflow-run/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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 index 51bb1bc3..fdf71de8 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 passed character node with non-completed phase', async () => { + const passedCharacterWithWrongPhase = { + ...nodes[0], + status: 'passed' as const, + phase: 'configuring_character' as const, + selectedImageUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [passedCharacterWithWrongPhase] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a completed character node without passed status', async () => { + const completedCharacterNotPassed = { + ...nodes[0], + status: 'active' as const, + phase: 'completed' as const, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [completedCharacterNotPassed] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a passed action node with non-completed phase', async () => { + const passedActionWithWrongPhase = { + ...nodes[1], + status: 'passed' as const, + phase: 'configuring_action' as const, + selectedFirstFrameUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [nodes[0], passedActionWithWrongPhase] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a completed action node without passed status', async () => { + const completedActionNotPassed = { + ...nodes[1], + status: 'active' as const, + phase: 'completed' as const, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [nodes[0], completedActionNotPassed] }), + ) + await expect(apis.get('17')).rejects.toMatchObject({ + name: 'ApiError', + kind: 'invalid-response', + }) + }) + + it('rejects a passed action node without selected first frame', async () => { + const passedActionWithoutSelection = { + ...nodes[1], + status: 'passed' as const, + phase: 'completed' as const, + selectedFirstFrameUrl: null, + } + const apis = await loadWorkflowRunApis(async () => + jsonResponse({ ...workflowRunDto, nodes: [nodes[0], passedActionWithoutSelection] }), + ) + 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 2e64314b..cf1dfbe2 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -77,14 +77,23 @@ function isCharacterNode(value: unknown): value is CharacterWorkflowNode { ) { 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)) - ) + // status/phase 矩阵:passed 必须对应 completed,非 passed 不能是 completed + if (value.status === 'passed' && value.phase !== 'completed') return false + if (value.phase === 'completed' && value.status !== 'passed') return false + if ( + typeof value.input.prompt !== 'string' || + !Array.isArray(value.input.referenceMedia) || + !value.input.referenceMedia.every((item) => typeof item === 'string') || + !isNullableString(value.selectedImageUrl) + ) { + return false + } + // passed 节点必须有已选资产,否则会错误解锁下游动作节点 + if (value.status === 'passed') { + return typeof value.selectedImageUrl === 'string' && value.selectedImageUrl.length > 0 + } + // 未完成节点不应持有已选资产 + return value.selectedImageUrl === null } function isActionNode(value: unknown): value is ActionWorkflowNode { @@ -102,21 +111,30 @@ function isActionNode(value: unknown): value is ActionWorkflowNode { ) { 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)) - ) + // status/phase 矩阵:passed 必须对应 completed,非 passed 不能是 completed + if (value.status === 'passed' && value.phase !== 'completed') return false + if (value.phase === 'completed' && value.status !== 'passed') return false + if ( + 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) + ) { + return false + } + // passed 节点必须有已选资产,否则会错误解锁下游 + if (value.status === 'passed') { + return typeof value.selectedFirstFrameUrl === 'string' && value.selectedFirstFrameUrl.length > 0 + } + // 未完成节点:只有 selecting_action_frame/completed 阶段才应有首帧 + return true } function isWorkflowNode(value: unknown): value is WorkflowNode { diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts index e8ba9c9c..62eafbdb 100644 --- a/frontend/src/entities/workflow-run/constants.ts +++ b/frontend/src/entities/workflow-run/constants.ts @@ -1,27 +1,14 @@ -/** 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', +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_NODE_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 新建角色时的基础节点顺序;之后可并发追加 action-full-frame / review 成对节点。 */ +export const WORKFLOW_NODE_ORDER = [ + 'character-setup', + 'character-template', + 'action-first-frame', + 'action-full-frame', + 'review', ] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 7b51b324..4fcdd480 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,95 +1,182 @@ -import type { ActionType } from '../character' -import type { Generation } from '../generation' +import type { + Generation, + CharacterImageGenerationInput, + CharacterImageOutput, + CharacterActionGenerationInput, + CharacterActionOutput, +} from '../generation' import type { MediaReference } from '../media' import { - WORKFLOW_GENERATION_ROLES, - WORKFLOW_NODE_PHASES, + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_PURPOSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_NODE_ORDER, WORKFLOW_NODE_STATUSES, - WORKFLOW_NODE_TYPES, - WORKFLOW_RUN_STORAGE_STATUSES, } from './constants' -export type WorkflowRunStorageStatus = (typeof WORKFLOW_RUN_STORAGE_STATUSES)[number] -export type WorkflowNodeType = (typeof WORKFLOW_NODE_TYPES)[number] +export { WORKFLOW_NODE_ORDER } from './constants' + +/** 创建 WorkflowRun 时要完成的用户意图。 */ +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] + +/** 前端流程节点类型,与 WORKFLOW_NODE_ORDER 的成员保持一致。 */ +export type WorkflowNodeType = (typeof WORKFLOW_NODE_ORDER)[number] + +/** + * 节点的可用性和执行结果;不直接复用后端任务状态。 + * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 + */ export type WorkflowNodeStatus = (typeof WORKFLOW_NODE_STATUSES)[number] -export type WorkflowNodePhase = (typeof WORKFLOW_NODE_PHASES)[number] -export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] -/** 一个节点对后端 GenerationTask 的引用;节点可关联零个、一个或多个任务。 */ -export interface WorkflowGenerationRef { - taskId: Generation['id'] - role: WorkflowGenerationRole -} +/** + * 整次流程的汇总状态。 + * interrupted 只表示用户主动停止自动推进,不等于 failed 或 completed。 + */ +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] + +/** 生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] + +/** 导出阶段的汇总状态。 */ +export type ExportStatus = (typeof EXPORT_STATUSES)[number] interface WorkflowNodeBase { + /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ id: string - type: WorkflowNodeType status: WorkflowNodeStatus - phase: WorkflowNodePhase /** - * 本节点的直接前置节点 ID。空数组表示图的入口;多个 ID 表示汇合依赖。 - * 边随节点一起存入后端 nodes JSON,不能再用数组位置猜测连线。 + * 本节点已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 + * 任务本身不认识节点,反向关联不存在。 + */ + taskId: Generation['id'] | null + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交。 */ - dependsOnNodeIds: string[] - generations: WorkflowGenerationRef[] + submissionId: string | null + /** 节点失败后供页面解释原因;未失败时必须为 null。 */ error: string | null } -export interface WorkflowCharacterInput { - prompt: string +/** 角色资料节点保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupNodeInput { + description: string referenceMedia: readonly MediaReference[] } -/** 角色节点内部完成资料填写、候选图生成和候选确认。 */ -export interface CharacterWorkflowNode extends WorkflowNodeBase { - type: 'character' - input: WorkflowCharacterInput - selectedImageUrl: string | null +export interface CharacterSetupWorkflowNode extends WorkflowNodeBase { + type: 'character-setup' + input: CharacterSetupNodeInput | null + output: null } -export interface WorkflowActionInput { - outfitId: string - name: string - type: ActionType - prompt: string | null - fps: number +export interface CharacterTemplateWorkflowNode extends WorkflowNodeBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterImageGenerationInput | null + output: CharacterImageOutput | null +} + +/** 首帧生成节点:生成单帧角色动作候选。 */ +export interface ActionFirstFrameWorkflowNode extends WorkflowNodeBase { + type: 'action-first-frame' + input: CharacterActionGenerationInput | null + output: CharacterActionOutput | null } -/** 一个 Action 对应一个节点;共同依赖同一节点的多个 Action 可以并行。 */ -export interface ActionWorkflowNode extends WorkflowNodeBase { - type: 'action' - input: WorkflowActionInput - selectedFirstFrameUrl: string | null +/** 完整帧率生成节点:基于首帧生成完整动画。 */ +export interface ActionFullFrameWorkflowNode extends WorkflowNodeBase { + type: 'action-full-frame' + input: CharacterActionGenerationInput | null + output: CharacterActionOutput | null } -/** 工作流图中的真实节点。前端和后端统一使用 node,不再保留 step 或假 root。 */ -export type WorkflowNode = CharacterWorkflowNode | ActionWorkflowNode +type RemainingWorkflowNodeType = Exclude< + WorkflowNodeType, + 'character-setup' | 'character-template' | 'action-first-frame' | 'action-full-frame' +> + +interface RemainingWorkflowNode extends WorkflowNodeBase { + type: RemainingWorkflowNodeType + /** 审核的具体输入输出在对应纵切中继续收窄。 */ + input: unknown + output: unknown +} + +/** + * 执行线中的流程节点。 + * 前四个执行节点已冻结输入输出;后续进入对应纵切时再收窄。 + */ +export type WorkflowNode = + | CharacterSetupWorkflowNode + | CharacterTemplateWorkflowNode + | ActionFirstFrameWorkflowNode + | ActionFullFrameWorkflowNode + | RemainingWorkflowNode /** - * 一次制作流程的持久化容器。Quick Start 与 Workflow Editor 只是不同界面; - * 两者读取和推进同一份节点图。 + * 一次由前端推进的页面流程。 + * + * 后端采用树状纯存储模型,不提供回退或版本历史能力。用户从旧节点重做时, + * 前端直接覆盖当前节点结果,不保留被废弃结果的历史链路。 + * 一个 Character 复用同一条 Run;新增动作不会创建第二条 Run。 */ export interface WorkflowRun { id: string projectId: string - /** 后端乐观版本号,每次 PATCH 后使用响应中的新值。 */ - version: number - /** 后端资源状态,仅表示正常或软删除。 */ - storageStatus: WorkflowRunStorageStatus - /** 真实节点图;节点间的边由 dependsOnNodeIds 表达。 */ + /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ + characterId: string | null + /** 已有角色加动作时的目标造型;新建角色时为 null。 */ + outfitId: string | null + /** 建立这条 Run 时的根意图;后续追加动作不会把 create_character 改写为 add_action。 */ + purpose: WorkflowRunPurpose + status: WorkflowRunStatus + /** + * 当前执行线中的节点。前三个节点串行推进,之后可随时追加 action-generation / review + * 成对节点。多个 action-generation 可并发——互不阻塞。数组位置是节点顺序的唯一来源。 + */ nodes: WorkflowNode[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ + prompt: string | null + createdAt: string } -export interface CreateWorkflowRunInput { +/** + * @deprecated WorkflowRevision 已合并到 WorkflowRun,直接用 WorkflowRun。 + */ +export type WorkflowRevision = WorkflowRun + +/** 创建 WorkflowRun 的共享字段。 */ +interface CreateWorkflowRunInputBase { projectId: string - nodes: WorkflowNode[] + /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ + prompt?: string } -export interface WorkflowRunApis { - create(input: CreateWorkflowRunInput): Promise - get(id: WorkflowRun['id']): Promise - update(run: WorkflowRun): Promise - remove(id: WorkflowRun['id']): Promise -} +/** + * 创建 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 { workflowRunApis } from './api' +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts new file mode 100644 index 00000000..3bf0c1a8 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_NODE_ORDER } from './constants' +import type { WorkflowRun, WorkflowNode } from './index' +import { createWorkflowRunStore } from './store' + +function createNodes(): WorkflowNode[] { + return WORKFLOW_NODE_ORDER.map((type, index) => { + const common = { + id: `run-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + } + if (type === 'character-setup') { + return { + ...common, + type, + input: { description: 'slime', referenceMedia: [] }, + output: null, + } + } + if (type === 'character-template') { + return { ...common, type, input: null, output: null } + } + return { ...common, type, input: null, output: null } as WorkflowNode + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + status: 'active', + nodes: createNodes(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + prompt: 'Create a slime', + createdAt: '2026-07-30T12:00:00.000Z', + } +} + +/** 后端响应包装:Response { code, message, data: T } */ +function wrapResponse(data: T) { + return { code: 0, message: 'ok', data } +} + +/** 前端 WorkflowRun → 后端 nodes[0] 载荷。与 store._toNodePayload 保持一致。 */ +function packNodes(run: WorkflowRun): Record { + return { + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + purpose: run.purpose, + status: run.status, + nodes: run.nodes, + generationStatus: run.generationStatus, + exportStatus: run.exportStatus, + prompt: run.prompt, + createdAt: run.createdAt, + } +} + +const BASE = '/workflow-runs' + +function createMockApi() { + // 后端内部使用前端 string ID 索引(保持简单); + // 响应时仍返回整数 ID,由被测 _fromBackend 转换回 string。 + const runs = new Map() + let nextNumericId = 1 + + const fetch = vi.fn(async (input: RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.url + const method = init?.method ?? 'GET' + + // POST /workflow-runs → create + if (method === 'POST' && url === BASE) { + const body = JSON.parse((init?.body as string) ?? '{}') + const node = body.nodes?.[0] ?? {} + const runId = `run-${nextNumericId}` + const run: WorkflowRun = { + id: runId, + // projectId 优先从 nodes 取(保留原始前端 string 值),回退到 project_id + projectId: + (node.projectId as string) ?? String(body.project_id ?? ''), + characterId: node.characterId ?? null, + outfitId: node.outfitId ?? null, + purpose: node.purpose ?? 'create_character', + status: node.status ?? 'active', + nodes: node.nodes ?? [], + generationStatus: node.generationStatus ?? 'not_started', + exportStatus: node.exportStatus ?? 'not_exported', + prompt: node.prompt ?? null, + createdAt: node.createdAt ?? new Date().toISOString(), + } + runs.set(runId, run) + return wrapResponse({ + id: nextNumericId++, + project_id: body.project_id, + nodes: [packNodes(run)], + status: 'active', + version: 1, + }) + } + + // GET /workflow-runs → list all (getByCharacter 回退) + if (method === 'GET' && url === BASE) { + return wrapResponse( + [...runs.entries()].map(([rid, run]) => ({ + id: Number(rid.split('-')[1] ?? rid), + project_id: Number(run.projectId.split('-')[1] ?? run.projectId), + nodes: [packNodes(run)], + status: 'active', + version: 1, + })), + ) + } + + // GET /workflow-runs?project_id=X → list by project + // GET /workflow-runs?characterId=X → list by character + if (method === 'GET' && url.startsWith(`${BASE}?`)) { + const params = new URLSearchParams(url.split('?')[1]) + const characterId = params.get('characterId') + const projectId = params.get('project_id') + const all = [...runs.entries()] + .filter(([, r]) => { + if (characterId) return r.characterId === characterId + if (projectId) return r.projectId === projectId + return true + }) + .map(([rid, run]) => ({ + id: Number(rid.split('-')[1] ?? rid), + project_id: Number(run.projectId.split('-')[1] ?? run.projectId), + nodes: [packNodes(run)], + status: 'active', + version: 1, + })) + return wrapResponse(all) + } + + // GET /workflow-runs/{id} → get by ID + if (method === 'GET' && url.startsWith(`${BASE}/`)) { + const numericId = url.split('/').pop()! + const runId = `run-${numericId}` + const run = runs.get(runId) + if (!run) throw Object.assign(new Error('Not Found'), { status: 404 }) + return wrapResponse({ + id: Number(numericId), + project_id: Number(run.projectId.split('-')[1] ?? run.projectId), + nodes: [packNodes(run)], + status: 'active', + version: 1, + }) + } + + // PATCH /workflow-runs/{id} → update + if (method === 'PATCH' && url.startsWith(`${BASE}/`)) { + const numericId = url.split('/').pop()! + const runId = `run-${numericId}` + if (!runs.has(runId)) + throw Object.assign(new Error('Not Found'), { status: 404 }) + const body = JSON.parse((init?.body as string) ?? '{}') + const node = body.nodes?.[0] ?? {} + const existing = runs.get(runId)! + const updated: WorkflowRun = { + id: runId, + projectId: String(body.project_id ?? existing.projectId), + characterId: + node.characterId !== undefined + ? node.characterId + : existing.characterId, + outfitId: + node.outfitId !== undefined ? node.outfitId : existing.outfitId, + purpose: node.purpose ?? existing.purpose, + status: node.status ?? existing.status, + nodes: node.nodes ?? existing.nodes, + generationStatus: + node.generationStatus ?? existing.generationStatus, + exportStatus: node.exportStatus ?? existing.exportStatus, + prompt: node.prompt !== undefined ? node.prompt : existing.prompt, + createdAt: node.createdAt ?? existing.createdAt, + } + runs.set(runId, updated) + return wrapResponse({ + id: Number(numericId), + project_id: Number(updated.projectId.split('-')[1] ?? updated.projectId), + nodes: [packNodes(updated)], + status: 'active', + version: 1, + }) + } + + throw Object.assign(new Error('Not Found'), { status: 404 }) + }) + + return { runs, fetch } +} + +describe('createWorkflowRunStore', () => { + it('creates a run and returns the server-persisted snapshot', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const run = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + prompt: 'A fire dragon', + }) + + expect(run.id).toBeTruthy() + expect(run.prompt).toBe('A fire dragon') + expect(run.purpose).toBe('create_character') + expect(api.fetch).toHaveBeenCalledWith( + BASE, + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('gets a run by ID', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + const created = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + }) + + const found = await store.get(created.id) + + expect(found?.id).toBe(created.id) + }) + + it('returns null when getting a non-existent run', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const result = await store.get('999') + + expect(result).toBeNull() + }) + + it('does not disguise a server failure as a missing run', async () => { + const failure = Object.assign(new Error('Service Unavailable'), { + status: 503, + }) + const store = createWorkflowRunStore({ + api: { fetch: vi.fn().mockRejectedValue(failure) }, + }) + + await expect(store.get('run-1')).rejects.toBe(failure) + await expect(store.getByCharacter('character-1')).rejects.toBe(failure) + }) + + it('finds the run bound to a character', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + const created = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + }) + created.characterId = 'character-1' + await store.save(created) + + const found = await store.getByCharacter('character-1') + + expect(found?.id).toBe(created.id) + expect(found?.characterId).toBe('character-1') + }) + + it('returns null when no run is bound to a character', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const result = await store.getByCharacter('missing') + + expect(result).toBeNull() + }) + + it('lists runs by project', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + await store.create({ projectId: 'project-1', purpose: 'create_character' }) + await store.create({ + projectId: 'project-1', + purpose: 'create_character', + prompt: '', + }) + + const runs = await store.list('project-1') + + expect(runs).toHaveLength(2) + expect(runs[0]?.projectId).toBe('project-1') + expect(runs[1]?.projectId).toBe('project-1') + }) + + it('saves a run and persists changes', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + const created = await store.create({ + projectId: 'project-1', + purpose: 'create_character', + }) + + created.status = 'completed' + await store.save(created) + + const reloaded = await store.get(created.id) + expect(reloaded?.status).toBe('completed') + }) + + it('creates an add_action run with required character fields', async () => { + const api = createMockApi() + const store = createWorkflowRunStore({ api }) + + const run = await store.create({ + projectId: 'project-1', + purpose: 'add_action', + characterId: 'char-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: ['https://example.com/frame1.png'], + }) + + expect(run.purpose).toBe('add_action') + expect(run.characterId).toBe('char-1') + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 00000000..2f22ddd8 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,240 @@ +import type { CreateWorkflowRunInput, WorkflowRun } from "./index"; + +/** + * WorkflowRun 持久化契约。 + * + * 持久化走服务端 API,所有方法均为异步。前端不保留 localStorage 副本, + * 也不提供 subscribe / subscribeAll——状态变更由前端逻辑自身驱动。 + * + * 后端 API 契约(对齐 commit 4246389b) + * -------------------------------- + * POST /workflow-runs 创建执行记录 + * GET /workflow-runs/{id} 获取执行记录(含 nodes JSONB) + * PATCH /workflow-runs/{id} 全量更新(含 nodes) + * DELETE /workflow-runs/{id} 软删除 + * + * 后端只做存储,不感知节点结构。前端 WorkflowRun 的完整状态(除 id / projectId 外) + * 序列化到后端 nodes 字段。id/projectId 映射为后端顶层 id/project_id。 + */ +export interface WorkflowRunStore { + /** 创建一条新的 WorkflowRun,返回服务端持久化后的完整快照。 */ + create(input: CreateWorkflowRunInput): Promise; + /** 按 ID 读取 WorkflowRun 最新快照;不存在时返回 null。 */ + get(runId: WorkflowRun["id"]): Promise; + /** 按已关联的 Character ID 查找唯一绑定的 WorkflowRun(客户端过滤)。 */ + getByCharacter(characterId: string): Promise; + /** 列出当前项目下的全部 WorkflowRun。 */ + list(projectId?: string): Promise; + /** 保存 WorkflowRun 最新状态到服务端。 */ + save(run: WorkflowRun): Promise; +} + +export interface CreateWorkflowRunStoreOptions { + /** + * HTTP 客户端,提供 fetch 方法。 + * 不传时使用仅内存存储(测试友好)。 + */ + api?: { fetch(input: RequestInfo, init?: RequestInit): Promise }; +} + +// ── 序列化 ───────────────────────────────────────────────────────────────── + +/** 后端 WorkflowRun 响应形状(nodes JSONB 透传)。 */ +interface BackendWorkflowRun { + id: number; + project_id: number; + nodes: Record[]; + status: string; + version: number; +} + +/** 把前端 WorkflowRun 的丰富字段序列化到后端 nodes 载荷中。 */ +function _toNodePayload(run: WorkflowRun): Record { + return { + // projectId 同时写进 nodes:后端 project_id 是整数,前端用 string ID, + // 读取时优先从 nodes 还原以保持原始值。 + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + purpose: run.purpose, + status: run.status, + nodes: run.nodes, + generationStatus: run.generationStatus, + exportStatus: run.exportStatus, + prompt: run.prompt, + createdAt: run.createdAt, + }; +} + +/** 从后端响应重建前端 WorkflowRun。 */ +function _fromBackend(b: BackendWorkflowRun): WorkflowRun { + const node = b.nodes[0] ?? {}; + return { + id: String(b.id), + // 优先从 nodes 取 projectId(保持前端原始 string 值), + // 不存时回退到后端 project_id。 + projectId: + (node.projectId as string) ?? String(b.project_id), + characterId: (node.characterId as string) ?? null, + outfitId: (node.outfitId as string) ?? null, + purpose: (node.purpose as WorkflowRun["purpose"]) ?? "create_character", + status: (node.status as WorkflowRun["status"]) ?? "active", + nodes: (node.nodes as WorkflowRun["nodes"]) ?? [], + generationStatus: + (node.generationStatus as WorkflowRun["generationStatus"]) ?? + "not_started", + exportStatus: + (node.exportStatus as WorkflowRun["exportStatus"]) ?? "not_exported", + prompt: (node.prompt as string | null) ?? null, + createdAt: (node.createdAt as string) ?? new Date().toISOString(), + }; +} + +// ── 内存存储(测试/过渡期) ────────────────────────────────────────────────── + +function createInMemoryStore(): WorkflowRunStore { + const runs = new Map(); + + return { + async create(input) { + const run: WorkflowRun = { + id: `run-${runs.size + 1}`, + projectId: input.projectId, + characterId: + "characterId" in input ? (input.characterId as string) : null, + outfitId: "outfitId" in input ? (input.outfitId as string) : null, + purpose: input.purpose, + status: "active", + nodes: [], + generationStatus: "not_started", + exportStatus: "not_exported", + prompt: input.prompt ?? null, + createdAt: new Date().toISOString(), + }; + runs.set(run.id, structuredClone(run)); + return structuredClone(run); + }, + + async get(runId) { + const run = runs.get(runId); + return run ? structuredClone(run) : null; + }, + + async getByCharacter(characterId) { + for (const run of runs.values()) { + if (run.characterId === characterId) return structuredClone(run); + } + return null; + }, + + async list(projectId) { + return [...runs.values()] + .filter((run) => !projectId || run.projectId === projectId) + .map((run) => structuredClone(run)); + }, + + async save(run) { + runs.set(run.id, structuredClone(run)); + }, + }; +} + +// ── HTTP 存储 ────────────────────────────────────────────────────────────── + +function isNotFoundError(cause: unknown): boolean { + return ( + typeof cause === "object" && + cause !== null && + "status" in cause && + cause.status === 404 + ); +} + +/** + * 创建 WorkflowRunStore。 + * 传入 api 时走 HTTP 持久化(对齐后端 /workflow-runs 接口), + * 否则使用仅内存存储(用于测试和过渡期)。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const api = options.api; + if (!api) return createInMemoryStore(); + + /** 后端通用响应包装:Response { code, message, data: T } */ + function _unwrap(response: unknown): T { + const r = response as { data?: T }; + if (r.data !== undefined) return r.data; + return response as T; + } + + return { + async create(input) { + const response = await api.fetch("/workflow-runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project_id: Number(input.projectId), + nodes: [ + { + // 创建时前端 WorkflowRun 字段(除 id)全部进入 nodes + projectId: input.projectId, + characterId: + "characterId" in input ? input.characterId : null, + outfitId: "outfitId" in input ? input.outfitId : null, + purpose: input.purpose, + status: "active", + nodes: [], + generationStatus: "not_started", + exportStatus: "not_exported", + prompt: input.prompt ?? null, + createdAt: new Date().toISOString(), + }, + ], + }), + }); + return _fromBackend(_unwrap(response) as BackendWorkflowRun); + }, + + async get(runId) { + try { + const response = await api.fetch(`/workflow-runs/${runId}`); + return _fromBackend(_unwrap(response) as BackendWorkflowRun); + } catch (cause) { + if (isNotFoundError(cause)) return null; + throw cause; + } + }, + + async getByCharacter(characterId) { + try { + // 后端无 characterId 查询参数,先全量拉取再客户端过滤。 + const runs = await api.fetch("/workflow-runs"); + const all = (_unwrap(runs) as BackendWorkflowRun[]).map(_fromBackend); + return all.find((r) => r.characterId === characterId) ?? null; + } catch (cause) { + if (isNotFoundError(cause)) return null; + throw cause; + } + }, + + async list(projectId) { + const query = projectId + ? `?project_id=${encodeURIComponent(projectId)}` + : ""; + const response = await api.fetch(`/workflow-runs${query}`); + const items = _unwrap(response) as BackendWorkflowRun[]; + return items.map(_fromBackend); + }, + + async save(run) { + await api.fetch(`/workflow-runs/${run.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodes: [_toNodePayload(run)], + }), + }); + }, + }; +} diff --git a/frontend/src/features/workflow-controller/action-generation-task.ts b/frontend/src/features/workflow-controller/action-generation-task.ts new file mode 100644 index 00000000..859415d1 --- /dev/null +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -0,0 +1,282 @@ +import { + CHARACTER_ACTION_FRAME_COUNT, + type CharacterActionGenerationInput, + type CharacterActionOutput, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRun, + type WorkflowRunStore, +} from "@/entities"; +import { + beginActionGenerationState, + completeActionGenerationState, + getActiveNode, + recordActionGenerationTaskState, +} from "./workflow-state"; + +interface ActiveSubscription { + runId: WorkflowRun["id"]; + stop: () => void; +} + +export interface ActionGenerationTask { + start( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ): Promise; + resume(runId: WorkflowRun["id"]): Promise; + stop(runId: WorkflowRun["id"]): void; +} + +interface CreateActionGenerationTaskOptions { + store: WorkflowRunStore; + generationApis: GenerationApis; + createSubmissionId: () => string; +} + +/** 管理完整动作生成的提交、订阅和刷新恢复,页面只负责提供业务输入。 */ +export function createActionGenerationTask({ + store, + generationApis, + createSubmissionId, +}: CreateActionGenerationTaskOptions): ActionGenerationTask { + const submissions = new Map>(); + const subscriptions = new Map(); + + async function requireRun(runId: WorkflowRun["id"]) { + const run = await store.get(runId); + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`); + return run; + } + + async function save(run: WorkflowRun) { + await store.save(run); + return run; + } + + async function currentActionNode(runId: WorkflowRun["id"]) { + const run = await requireRun(runId); + const node = getActiveNode(run); + return { run, node }; + } + + async function start( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ) { + const { run, node } = await currentActionNode(runId); + if (run.status !== "active" || node?.type !== "action-generation") + return run; + if (node.taskId) { + subscribe(run, node.taskId); + return run; + } + if (node.submissionId) + throw new Error("动作生成请求仍在等待后端确认,不能重复提交"); + + const key = `${runId}:${node.id}`; + const pending = submissions.get(key); + if (pending) return pending; + const submission = submit(runId, input).finally(() => + submissions.delete(key), + ); + submissions.set(key, submission); + return submission; + } + + async function submit( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ) { + const submissionId = createSubmissionId(); + await save( + beginActionGenerationState(await requireRun(runId), input, submissionId), + ); + try { + const generation = await generationApis.create(input); + const latest = await requireRun(runId); + const node = getActiveNode(latest); + if ( + (latest.status !== "active" && latest.status !== "interrupted") || + node?.type !== "action-generation" || + node.submissionId !== submissionId + ) { + return latest; + } + if (generation.type !== "character_action") { + throw new Error("生成任务类型与动作生成节点不匹配"); + } + const withTask = await save( + recordActionGenerationTaskState(latest, generation.id, input), + ); + if (latest.status === "interrupted") return withTask; + if (generation.status === "pending" || generation.status === "running") { + subscribe(withTask, generation.id); + return withTask; + } + return await applyTerminal(runId, generation.id, generation); + } catch (cause) { + const latest = await store.get(runId); + if (latest?.status === "active") { + const node = getActiveNode(latest); + if (node?.type === "action-generation") { + await save( + completeActionGenerationState(latest, { + error: message(cause, "动作生成请求失败"), + }), + ); + } + } + throw cause instanceof Error ? cause : new Error(String(cause)); + } + } + + function subscribe(run: WorkflowRun, taskId: string) { + const key = `${run.id}:${taskId}`; + if (subscriptions.has(key)) return; + subscriptions.set(key, { runId: run.id, stop: () => undefined }); + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + if ( + event.taskId !== taskId || + event.status === "pending" || + event.status === "running" + ) + return; + void applyTerminal(run.id, taskId, event).catch(async (cause) => { + console.error("[workflow] 保存动作生成终态失败,正在重新查询", cause); + try { + const task = await generationApis.get(run.projectId, taskId); + await applyTerminal(run.id, taskId, task); + } catch (retryCause) { + console.error("[workflow] 重新保存动作生成终态失败", retryCause); + } + }); + }); + const active = subscriptions.get(key); + if (active) subscriptions.set(key, { ...active, stop }); + else stop(); + } catch (cause) { + subscriptions.delete(key); + throw cause; + } + } + + async function applyTerminal( + runId: WorkflowRun["id"], + taskId: string, + task: Generation | GenerationEvent, + ) { + const latest = await requireRun(runId); + if (latest.status !== "active") return latest; + const node = getActiveNode(latest); + if (node?.type !== "action-generation" || node.taskId !== taskId) + return latest; + stopSubscription(runId, taskId); + if (task.status === "failed") { + return save( + completeActionGenerationState(latest, { + error: task.error?.trim() || "动作生成任务失败", + }), + ); + } + const result = task.result; + if ( + task.type !== "character_action" || + result?.type !== "character_action" || + result.frames.length === 0 + ) { + return save( + completeActionGenerationState(latest, { + error: "动作生成完成但未返回有效动画帧", + }), + ); + } + const completeResult = result as CharacterActionOutput; + const frameCountError = getCharacterActionFrameCountError(completeResult); + return save( + completeActionGenerationState( + latest, + frameCountError ? { error: frameCountError } : completeResult, + ), + ); + } + + async function resume(runId: WorkflowRun["id"]) { + const run = await store.get(runId); + if (!run || run.status !== "active") return run; + const node = getActiveNode(run); + if (node?.type !== "action-generation") return run; + if (node.submissionId && !node.taskId) { + return save( + completeActionGenerationState(run, { + error: "页面刷新时动作生成请求尚未返回任务 ID,请重新开始该节点", + }), + ); + } + if (!node.taskId) { + if (node.input) return start(runId, node.input); + return save( + completeActionGenerationState(run, { + error: "动作生成尚未完成提交,请重新确认角色候选", + }), + ); + } + try { + const task = await generationApis.get(run.projectId, node.taskId); + if (task.status === "pending" || task.status === "running") { + subscribe(run, node.taskId); + return store.get(runId); + } + return await applyTerminal(runId, node.taskId, task); + } catch (cause) { + // 查询失败只说明当前无法确认后端任务状态,不能把仍在运行的权威任务写成失败。 + throw cause instanceof Error ? cause : new Error(String(cause)); + } + } + + function stopSubscription(runId: string, taskId: string) { + const key = `${runId}:${taskId}`; + const active = subscriptions.get(key); + subscriptions.delete(key); + try { + active?.stop(); + } catch { + // 停止订阅失败不能破坏已经保存的工作流状态。 + } + } + + function stop(runId: WorkflowRun["id"]) { + for (const [key, active] of subscriptions) { + if (active.runId !== runId) continue; + subscriptions.delete(key); + try { + active.stop(); + } catch { + // 同上。 + } + } + } + + return { start, resume, stop }; +} + +/** + * Controller 的完整动画验收门槛。生成服务负责补帧,WorkflowRun 只接收恰好 32 帧的 + * 新结果;这里不修改结果数组,避免把后端缺帧静默伪装成成功。 + */ +export function getCharacterActionFrameCountError( + result: CharacterActionOutput, +): string | null { + const actualFrameCount = result.frames.length; + return actualFrameCount === CHARACTER_ACTION_FRAME_COUNT + ? null + : `动作生成应返回 ${CHARACTER_ACTION_FRAME_COUNT} 帧,实际返回 ${actualFrameCount} 帧`; +} + +function message(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() + ? cause.message.trim() + : fallback; +} diff --git a/frontend/src/features/workflow-controller/character-template-task.ts b/frontend/src/features/workflow-controller/character-template-task.ts new file mode 100644 index 00000000..6fc1cb2c --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,492 @@ +import { + type CharacterImageOutput, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowNode, +} from "@/entities"; +import { + getActiveNode, + replaceWorkflowNode, + type WorkflowNodeTarget, +} from "./workflow-state"; + +interface ApplyServerResultInput extends WorkflowNodeTarget { + taskId: string; + result: unknown; +} + +interface ActiveSubscription { + runId: WorkflowRun["id"]; + stop: () => void; +} + +export interface CharacterTemplateTask { + start( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + ): Promise; + resume(runId: WorkflowRun["id"]): Promise; + stop(runId: WorkflowRun["id"]): void; +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore; + generationApis: GenerationApis; + createSubmissionId: () => string; +} + +/** 管理角色图生成的提交、任务关联、订阅和刷新恢复。 */ +export function createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>(); + const subscriptions = new Map(); + + async function getWorkflow(runId: WorkflowRun["id"]) { + return store.get(runId); + } + + async function requireWorkflow(runId: WorkflowRun["id"]) { + const run = await getWorkflow(runId); + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`); + return run; + } + + async function save(run: WorkflowRun) { + await store.save(run); + return run; + } + + async function start( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + ): Promise { + const run = await requireWorkflow(runId); + const node = run.nodes.find((item) => item.id === target.nodeId); + if ( + run.id !== target.runId || + !node || + node.type !== "character-template" || + node.status !== "active" + ) { + return run; + } + if (node.taskId) { + ensureTaskSubscription(run, node.id, node.taskId); + return requireWorkflow(runId); + } + if (!node.input) throw new Error("角色图生成节点缺少输入快照"); + return submit(runId, target); + } + + function submit(runId: WorkflowRun["id"], target: WorkflowNodeTarget) { + const key = submissionKey(runId, target.nodeId); + const pending = submissions.get(key); + if (pending) return pending; + + const submission = performSubmission(runId, target).finally(() => + submissions.delete(key), + ); + submissions.set(key, submission); + return submission; + } + + async function performSubmission( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + ): Promise { + const before = await requireWorkflow(runId); + const beforeNode = before.nodes.find((node) => node.id === target.nodeId); + if ( + before.status !== "active" || + before.id !== target.runId || + !beforeNode || + beforeNode.type !== "character-template" || + beforeNode.status !== "active" || + !beforeNode.input + ) { + return before; + } + if (beforeNode.taskId) { + ensureTaskSubscription(before, beforeNode.id, beforeNode.taskId); + return before; + } + if (beforeNode.submissionId) { + throw new Error("角色图生成请求仍在等待后端确认,不能重复提交"); + } + + const submissionId = createSubmissionId(); + await save( + replaceWorkflowNode(before, beforeNode.id, (current) => + current.type === "character-template" + ? { ...current, submissionId } + : current, + ), + ); + + try { + const generation = await generationApis.create(beforeNode.input); + const latest = await requireWorkflow(runId); + const latestNode = latest.nodes.find((node) => node.id === target.nodeId); + if ( + (latest.status !== "active" && latest.status !== "interrupted") || + latest.id !== target.runId || + !latestNode || + latestNode.type !== "character-template" || + latestNode.status !== "active" || + latestNode.taskId || + latestNode.submissionId !== submissionId + ) { + return latest; + } + const projectMatches = + generation.projectId == null || + latest.projectId == null || + String(generation.projectId) === String(latest.projectId); + if (generation.type !== "character_image" || !projectMatches) { + throw new Error( + `生成任务返回的类型或项目与当前 WorkflowRun 不匹配 ` + + `(type: ${generation.type}, project: ${generation.projectId} vs ${latest.projectId})`, + ); + } + + const withTask = await save( + replaceWorkflowNode(latest, latestNode.id, (current) => + current.type === "character-template" + ? { ...current, taskId: generation.id, submissionId: null } + : current, + ), + ); + if (latest.status === "interrupted") return withTask; + if (generation.status === "failed") { + return await markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || "角色图生成任务失败", + ); + } + if (generation.status === "completed") { + return await applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }); + } + + ensureTaskSubscription(withTask, latestNode.id, generation.id); + return await requireWorkflow(runId); + } catch (cause) { + await markFailed( + runId, + target, + null, + submissionId, + errorMessage(cause, "角色图生成请求失败"), + ); + throw cause instanceof Error ? cause : new Error(String(cause)); + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + nodeId: WorkflowNode["id"], + taskId: string, + ) { + const key = subscriptionKey(run.id, nodeId, taskId); + if (subscriptions.has(key)) return; + + subscriptions.set(key, { runId: run.id, stop: () => undefined }); + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + void handleGenerationEvent( + run.id, + { runId: run.id, nodeId }, + taskId, + event, + ).catch((cause) => { + console.error("[workflow] 保存角色图生成终态失败,正在重新查询", cause); + void generationApis + .get(run.projectId, taskId) + .then((task) => + handleGenerationEvent( + run.id, + { runId: run.id, nodeId }, + taskId, + taskEvent(task), + ), + ) + .catch((retryCause) => { + console.error("[workflow] 重新保存角色图生成终态失败", retryCause); + }); + }); + }); + const active = subscriptions.get(key); + if (active) subscriptions.set(key, { ...active, stop }); + else stop(); + } catch (cause) { + subscriptions.delete(key); + throw cause; + } + } + + async function handleGenerationEvent( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + taskId: string, + event: GenerationEvent, + ) { + if ( + event.taskId !== taskId || + event.status === "pending" || + event.status === "running" + ) + return; + if (event.status === "failed") { + await markFailed( + runId, + target, + taskId, + null, + event.error?.trim() || "角色图生成任务失败", + ); + return; + } + if (event.type !== "character_image") { + await markFailed( + runId, + target, + taskId, + null, + "任务结果类型与角色图生成节点不匹配", + ); + return; + } + await applyServerResult(runId, { ...target, taskId, result: event.result }); + } + + async function resume(runId: WorkflowRun["id"]): Promise { + const run = await getWorkflow(runId); + if (!run || run.status !== "active") return run; + const activeNode = getActiveNode(run); + if ( + activeNode?.type !== "character-template" || + activeNode.status !== "active" + ) + return run; + const target = { runId: run.id, nodeId: activeNode.id }; + + if (activeNode.submissionId && !activeNode.taskId) { + if (submissions.has(submissionKey(run.id, activeNode.id))) return run; + return await markFailed( + run.id, + target, + null, + activeNode.submissionId, + "页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交", + ); + } + if (!activeNode.taskId) return run; + + const task = await generationApis.get(run.projectId, activeNode.taskId); + const latest = await getWorkflow(run.id); + if (!latest || latest.status !== "active") return latest; + const latestNode = latest.nodes.find((node) => node.id === activeNode.id); + if ( + latestNode?.type !== "character-template" || + latestNode.status !== "active" || + latestNode.taskId !== activeNode.taskId + ) { + return latest; + } + if (task.id !== latestNode.taskId) { + throw new Error("任务查询结果与 WorkflowRun 记录的 taskId 不匹配"); + } + if (task.type !== "character_image") { + return await markFailed( + latest.id, + { runId: latest.id, nodeId: latestNode.id }, + latestNode.taskId, + null, + "任务查询结果类型与角色图生成节点不匹配", + ); + } + if (task.status === "pending" || task.status === "running") { + ensureTaskSubscription(latest, latestNode.id, latestNode.taskId); + } else { + await handleGenerationEvent( + latest.id, + { runId: latest.id, nodeId: latestNode.id }, + latestNode.taskId, + taskEvent(task), + ); + } + return getWorkflow(runId); + } + + async function applyServerResult( + runId: WorkflowRun["id"], + input: ApplyServerResultInput, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active" || run.id !== input.runId) return run; + + const node = run.nodes.find((item) => item.id === input.nodeId); + if ( + !node || + node.type !== "character-template" || + node.status !== "active" || + node.taskId !== input.taskId + ) { + return run; + } + const result = parseCharacterImageOutput(input.result); + if (!result || result.imageUrls.length === 0) { + return await markFailed( + runId, + { runId: run.id, nodeId: node.id }, + input.taskId, + null, + "角色图生成任务返回了无法识别的结果", + ); + } + const candidateStep = run.nodes.find( + (item) => item.type === "action-first-frame", + ); + if (!candidateStep) + throw new Error("WorkflowRun 缺少 action-first-frame 节点"); + + const updated: WorkflowRun = { + ...run, + nodes: run.nodes.map((current) => { + if (current.id === node.id && current.type === "character-template") { + return { + ...current, + status: "passed" as const, + output: result, + taskId: null, + submissionId: null, + }; + } + if ( + current.id === candidateStep.id && + current.type === "action-first-frame" + ) { + return { ...current, status: "active" as const }; + } + return current; + }), + }; + stopSubscription(subscriptionKey(run.id, node.id, input.taskId)); + return save(updated); + } + + async function markFailed( + runId: WorkflowRun["id"], + target: WorkflowNodeTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = await requireWorkflow(runId); + if (run.status !== "active" || run.id !== target.runId) return run; + const node = run.nodes.find((item) => item.id === target.nodeId); + if ( + !node || + node.type !== "character-template" || + node.status !== "active" || + (expectedTaskId !== null && node.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && + node.submissionId !== expectedSubmissionId) + ) { + return run; + } + + const failureMessage = error.trim() || "角色图生成失败"; + const failed = replaceWorkflowNode(run, node.id, (current) => + current.type === "character-template" + ? { + ...current, + status: "failed", + taskId: null, + submissionId: null, + error: failureMessage, + } + : current, + ); + if (node.taskId) + stopSubscription(subscriptionKey(run.id, node.id, node.taskId)); + return save({ ...failed, status: "failed", generationStatus: "failed" }); + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key); + subscriptions.delete(key); + try { + subscription?.stop(); + } catch { + // 停止订阅失败不能破坏已经保存的工作流状态。 + } + } + + function stop(runId: WorkflowRun["id"]) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key); + } + } + + return { start, resume, stop }; +} + +function parseCharacterImageOutput( + value: unknown, +): CharacterImageOutput | null { + if ( + !value || + typeof value !== "object" || + !("type" in value) || + !("imageUrls" in value) + ) { + return null; + } + if (value.type !== "character_image" || !Array.isArray(value.imageUrls)) + return null; + const imageUrls = value.imageUrls.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + return imageUrls.length > 0 ? { type: "character_image", imageUrls } : null; +} + +function taskEvent(task: Generation): GenerationEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + }; +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() + ? cause.message.trim() + : fallback; +} + +function subscriptionKey( + runId: WorkflowRun["id"], + nodeId: WorkflowNode["id"], + taskId: string, +) { + return `${runId}:${nodeId}:${taskId}`; +} + +function submissionKey(runId: WorkflowRun["id"], nodeId: WorkflowNode["id"]) { + return `${runId}:${nodeId}`; +} diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 97fd2809..58d0dff5 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1,638 +1,307 @@ -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 { +import { describe, expect, it, vi } from "vitest"; + +import { + CHARACTER_ACTION_FRAME_COUNT, + type Character, + type CharacterApis, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type WorkflowRun, + type WorkflowRunStore, +} from "@/entities"; +import { createWorkflowController } from "."; + +const NOW = "2026-07-30T12:00:00.000Z"; + +function createStore(): WorkflowRunStore { + const runs = new Map(); 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', + async create(input) { + return { + id: `run-${runs.size + 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) } + characterId: input.purpose === "add_action" ? input.characterId : null, + outfitId: input.purpose === "add_action" ? input.outfitId : null, + purpose: input.purpose, + status: "active", + nodes: [], + generationStatus: "not_started", + exportStatus: "not_exported", + prompt: input.prompt ?? null, + createdAt: NOW, + }; + }, + async get(id) { + const run = runs.get(id); + return run ? structuredClone(run) : null; + }, + async getByCharacter(characterId) { + const run = [...runs.values()].find( + (item) => item.characterId === characterId, + ); + return run ? structuredClone(run) : null; + }, + async list(projectId) { + return [...runs.values()] + .filter((run) => !projectId || run.projectId === projectId) + .map((run) => structuredClone(run)); + }, + async save(run) { + runs.set(run.id, structuredClone(run)); + }, + }; } -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) +function createHarness(characterApis?: CharacterApis) { + const listeners = new Map void>(); + const createGeneration: GenerationApis["create"] = async < + T extends GenerationInput, + >( + input: T, + ) => + ({ + id: input.type === "character_image" ? "task-image-1" : "task-action-1", + projectId: input.projectId, + type: input.type, + status: "pending", + result: null, + error: null, + }) as Generation; + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error("not used"); }), - subscribe: vi.fn((_projectId, id, onEvent) => { - listeners.set(id, onEvent) - return () => listeners.delete(id) + subscribe: vi.fn((_projectId, taskId, onEvent) => { + listeners.set(taskId, onEvent); + return () => listeners.delete(taskId); }), - } - - 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 store = createStore(); 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)) + store, + generationApis, + characterApis, + now: () => NOW, + createId: () => "submission-1", + }); + return { + controller, + store, + emit(taskId: string, event: GenerationEvent) { + const listener = listeners.get(taskId); + if (!listener) throw new Error(`missing listener ${taskId}`); + listener(event); + }, + }; } -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, - }, - }) +describe("createWorkflowController", () => { + it("creates and persists the frontend-owned node graph", async () => { + const { controller, store } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "pixel knight", + }); + + expect(run.nodes).toHaveLength(5); + expect(run.nodes[0]).toMatchObject({ + type: "character-setup", + status: "active", + }); + expect(await store.get(run.id)).toEqual(run); + }); + + it("notifies page subscribers whenever the persisted snapshot changes", async () => { + const { controller } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + const listener = vi.fn(); + const unsubscribe = controller.subscribe(run.id, listener); + + await controller.updateCharacterSetup(run.id, { + description: "revised knight", + referenceMedia: [], + }); - expect(controller.getWorkflow().nodes).toEqual([ + expect(listener).toHaveBeenCalledWith( expect.objectContaining({ - id: 'character-1', - status: 'active', - phase: 'configuring_character', - generations: [], + nodes: expect.arrayContaining([ + expect.objectContaining({ + type: "character-setup", + input: expect.objectContaining({ description: "revised knight" }), + }), + ]), }), - 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('后端保存失败')) + ); + unsubscribe(); + }); + + it("rolls the page cache back when persistence fails", async () => { + const { controller, store } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + const listener = vi.fn(); + controller.subscribe(run.id, listener); + store.save = vi.fn().mockRejectedValue(new Error("save failed")); 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', + controller.updateCharacterSetup(run.id, { + description: "must not appear as saved", + referenceMedia: [], }), - { - 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' } }, + ).rejects.toThrow("save failed"); + + expect(controller.getWorkflow(run.id)).toEqual(run); + expect(listener).toHaveBeenLastCalledWith(run); + }); + + it("moves from setup to candidate selection when the image task completes", async () => { + const { controller, emit } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "pixel knight", + }); + await controller.nextStep(run.id, { width: 64, height: 64 }); + + emit("task-image-1", { + taskId: "task-image-1", + type: "character_image", + status: "completed", 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`, - })), + type: "character_image", + imageUrls: ["https://example.com/knight.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' }, + }); + + await vi.waitFor(() => { + expect(controller.getWorkflow(run.id)?.nodes[2]).toMatchObject({ + type: "action-first-frame", + status: "active", + }); + }); + }); + + it("keeps interruption as frontend state and preserves the active node", async () => { + const { controller } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + + const interrupted = await controller.interrupt(run.id); + + expect(interrupted.status).toBe("interrupted"); + expect( + interrupted.nodes.filter((node) => node.status === "active"), + ).toHaveLength(1); + }); + + it("deduplicates concurrent character creation for one candidate", async () => { + const character: Character = { + id: "character-1", + projectId: "project-1", + createdAt: NOW, + updatedAt: NOW, + outfits: [ + { + id: "outfit-1", + characterId: "character-1", + name: "默认造型", + candidateCharacterTemplates: [], + characterTemplateUrl: "https://example.com/knight.png", + baseFrames: [], + actions: [], + }, ], - }) - }) - - 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' } }, + }; + let releaseCreate: ((character: Character) => void) | undefined; + const characterApis: CharacterApis = { + get: vi.fn(async () => character), + listByProject: vi.fn(async () => [character]), + create: vi.fn( + () => + new Promise((resolve) => { + releaseCreate = resolve; + }), + ), + update: vi.fn(async (updated) => updated), + remove: vi.fn(async () => undefined), + }; + const { controller, emit } = createHarness(characterApis); + const run = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "pixel knight", + }); + await controller.nextStep(run.id, { width: 64, height: 64 }); + emit("task-image-1", { + taskId: "task-image-1", + type: "character_image", + status: "completed", 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') - }) -}) + result: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + await vi.waitFor(() => { + expect(controller.getWorkflow(run.id)?.nodes[2]?.status).toBe("active"); + }); + + const first = controller.startActionFromTemplate( + run.id, + "https://example.com/knight.png", + ); + const second = controller.startActionFromTemplate( + run.id, + "https://example.com/knight.png", + ); + await vi.waitFor(() => expect(characterApis.create).toHaveBeenCalledOnce()); + releaseCreate?.(character); + await Promise.all([first, second]); + + expect(characterApis.create).toHaveBeenCalledOnce(); + }); + + it("rejects an action result that is not exactly 32 frames", async () => { + const { controller } = createHarness(); + const run = await controller.create({ + projectId: "project-1", + purpose: "add_action", + characterId: "character-1", + outfitId: "outfit-1", + characterTemplateUrl: "template.png", + baseFrameUrls: [], + }); + const result = await controller.completeActionGeneration(run.id, { + type: "character_action", + actionType: "idle", + frames: Array.from( + { length: CHARACTER_ACTION_FRAME_COUNT - 1 }, + (_, index) => ({ + index, + imageUrl: `frame-${index}.png`, + durationMs: null, + }), + ), + }); + + expect(result.status).toBe("failed"); + expect( + result.nodes.find((node) => node.type === "action-generation"), + ).toMatchObject({ + status: "failed", + error: expect.stringContaining("32 帧"), + }); + }); +}); diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 2a17e403..f58a8dcb 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,796 +1,651 @@ import type { - ActionWorkflowNode, - CharacterTemplateGenerationInput, - CharacterWorkflowNode, - CompleteAnimationGenerationInput, - CreateWorkflowRunInput, - FirstFrameGenerationInput, - Generation, + CharacterApis, + CharacterSetupNodeInput, + CharacterActionGenerationInput, + CharacterActionOutput, GenerationApis, - GenerationEvent, MediaReference, - WorkflowActionInput, - WorkflowGenerationRef, - WorkflowGenerationRole, - WorkflowNode, WorkflowRun, - WorkflowRunApis, -} from '@/entities' + WorkflowRunStore, +} from "@/entities"; +import { publishWorkflowRun } from "@/features/publish"; +import { + createActionGenerationTask, + getCharacterActionFrameCountError, +} from "./action-generation-task"; +import { createCharacterTemplateTask } from "./character-template-task"; +import { + advanceCharacterSetupState, + appendActionState, + acceptUploadedCharacterTemplateState, + approveReviewState, + completeActionGenerationState, + confirmFirstFrameState, + createWorkflowRunState, + getActiveNode, + interruptWorkflowRunState, + recordActionGenerationTaskState, + restartWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from "./workflow-state"; + +/** 创建角色与给已有角色增加动作共用同一条运行状态机。 */ +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput; -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 WorkflowController { + /** 创建前端执行线,并把完整快照保存到持久化端。 */ + create(input: CreateWorkflowControllerInput): Promise; + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun["id"]): WorkflowRun | null; + + /** 按 Character 定位其唯一制作 Run;新增动作必须优先复用该 Run。 */ + getWorkflowByCharacter(characterId: string): Promise; + + /** 订阅当前页面会话中的运行状态;持久化实现不承担 UI 通知。 */ + subscribe( + runId: WorkflowRun["id"], + listener: (run: WorkflowRun) => void, + ): () => void; + + /** 在同一条已完成 Run 中追加新的动作生成与审核节点。 */ + appendAction(runId: WorkflowRun["id"]): Promise; + + /** 修改当前角色资料节点,页面无需知道节点内部 ID。 */ + updateCharacterSetup( + runId: WorkflowRun["id"], + input: CharacterSetupNodeInput, + ): Promise; + + /** 采用已上传的角色母版,跳过图片生成与候选选择并激活动作生成。 */ + acceptUploadedCharacterTemplate( + runId: WorkflowRun["id"], + templateUrl: MediaReference, + ): Promise; + + /** + * 推进一个节点。当前纵切只实现角色资料到角色图生成; + * 后续节点进入各自实现 PR 后再扩展,不在这里伪造完成。 + * spriteSize 为项目精灵图尺寸,角色图生成节点需要传给后端做尺寸校验。 + */ + nextStep( + runId: WorkflowRun["id"], + spriteSize?: { width: number; height: number }, + ): Promise; + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun["id"]): Promise; + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun["id"]): Promise; + + /** 确认首帧生成完成,推进到完整帧率生成。 */ + confirmFirstFrame(runId: WorkflowRun["id"]): Promise; + + /** + * 采用已确认的角色母版,并统一完成 Character 落库、Run 绑定与动作任务提交。 + * Quick Start 和 Workflow Editor 都调用这个命令,页面不再各自复制业务编排。 + */ + startActionFromTemplate( + runId: WorkflowRun["id"], + templateImageUrl: string, + actionDescription?: string, + ): Promise; + + /** 动作生成完成后写回结果,标记当前动作节点为 passed。 */ + completeActionGeneration( + runId: WorkflowRun["id"], + result: CharacterActionOutput | { error: string }, + ): Promise; + + /** 提交完整动作生成,并由 Controller 统一处理订阅和刷新恢复。 */ + startActionGeneration( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ): Promise; + + /** 审核通过后完成当前版本和整条运行;不在这里执行发布或下载。 */ + approveReview(runId: WorkflowRun["id"]): Promise; + + /** 审核当前动作并写入正式 Character;发布失败后允许用同一 Run 重试。 */ + approveAndPublish(runId: WorkflowRun["id"]): Promise; + + /** 动作生成任务提交后把任务 ID 落盘,供页面刷新后 resume 恢复轮询。 */ + recordActionGenerationTask( + runId: WorkflowRun["id"], + taskId: string, + ): Promise; + + /** 记录动作生成关联的角色与造型 ID,供导出到 Playtest 使用(刷新后可恢复)。 */ + recordCharacterRefs( + runId: WorkflowRun["id"], + refs: { characterId: string; outfitId: string }, + ): Promise; + + /** 从当前执行线中一个已通过的节点重新开始。 */ + restart(runId: WorkflowRun["id"], nodeId: string): Promise; } export interface CreateWorkflowControllerOptions { - /** 已从 WorkflowRunApis.get 取回的运行记录;不传时只能先调用 create。 */ - workflow?: WorkflowRun - workflowRunApis: WorkflowRunApis - generationApis: GenerationApis - createId?: () => string - /** SSE 回调无法 await,异步保存错误通过此处交给装配层展示或记录。 */ - onAsyncError: (error: Error) => void + store: WorkflowRunStore; + generationApis: GenerationApis; + /** 创建角色流程需要该接口;只操作已有角色动作时可不配置。 */ + characterApis?: CharacterApis; + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: "run" | "submission") => string; + /** 测试可注入确定性时间。 */ + now?: () => string; } /** - * 一个 Controller 只维护一条 WorkflowRun。 + * Quick Start 与手动工作流共用的流程协调器。 * - * Quick Start 与 Workflow Editor 调用同一组业务方法,区别只在于前者自动选择并连续 - * 调用、后者等待用户逐步点击。Controller 不识别入口,也不保存第二份流程模型。 + * Controller 只负责读取当前节点、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 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, + store, generationApis, - createId = createBrowserSafeId, - onAsyncError, + characterApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), }: 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 cache = new Map(); + const listeners = new Map< + WorkflowRun["id"], + Set<(run: WorkflowRun) => void> + >(); + const saveQueues = new Map>(); + const persistedSnapshots = new Map(); + const mutationVersions = new Map(); + const templateActionSubmissions = new Map< + WorkflowRun["id"], + Promise + >(); + + function notify(run: WorkflowRun) { + const snapshot = structuredClone(run); + for (const listener of listeners.get(snapshot.id) ?? []) { + try { + listener(structuredClone(snapshot)); + } catch { + // 一个页面订阅者渲染失败不能阻断持久化,也不能影响其他订阅者。 } - 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 structuredClone(snapshot); + } + + function rememberStored(run: WorkflowRun) { + const snapshot = structuredClone(run); + cache.set(snapshot.id, snapshot); + persistedSnapshots.set(snapshot.id, structuredClone(snapshot)); + return notify(snapshot); + } + + async function load(runId: WorkflowRun["id"]) { + const cached = cache.get(runId); + if (cached) return structuredClone(cached); + const stored = await store.get(runId); + return stored ? rememberStored(stored) : null; + } + + async function persist(run: WorkflowRun) { + const snapshot = structuredClone(run); + const version = (mutationVersions.get(snapshot.id) ?? 0) + 1; + mutationVersions.set(snapshot.id, version); + cache.set(snapshot.id, structuredClone(snapshot)); + // 同一 Run 的网络写入必须保持调用顺序,避免较慢的旧请求最后落库覆盖新状态。 + const previous = saveQueues.get(snapshot.id) ?? Promise.resolve(); + const pending = previous + .catch(() => undefined) + .then(() => store.save(structuredClone(snapshot))); + saveQueues.set(snapshot.id, pending); + try { + await pending; + persistedSnapshots.set(snapshot.id, structuredClone(snapshot)); + if (mutationVersions.get(snapshot.id) === version) notify(snapshot); + } catch (cause) { + if (mutationVersions.get(snapshot.id) === version) { + const fallback = persistedSnapshots.get(snapshot.id); + if (fallback) { + cache.set(snapshot.id, structuredClone(fallback)); + notify(fallback); + } else { + cache.delete(snapshot.id); } - 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 }) + throw cause; + } finally { + if (saveQueues.get(snapshot.id) === pending) + saveQueues.delete(snapshot.id); + } + return snapshot; + } + + const taskStore: WorkflowRunStore = { + create: (input) => store.create(input), + get: load, + getByCharacter: async (characterId) => { + const cached = [...cache.values()].find( + (run) => run.characterId === characterId, + ); + if (cached) return structuredClone(cached); + const stored = await store.getByCharacter(characterId); + return stored ? rememberStored(stored) : null; + }, + list: (projectId) => store.list(projectId), + save: async (run) => { + await persist(run); + }, + }; + + const characterTemplateTask = createCharacterTemplateTask({ + store: taskStore, + generationApis, + createSubmissionId: () => createId("submission"), + }); + const actionGenerationTask = createActionGenerationTask({ + store: taskStore, + generationApis, + createSubmissionId: () => createId("submission"), + }); + + function getWorkflow(runId: WorkflowRun["id"]) { + const run = cache.get(runId); + return run ? structuredClone(run) : null; + } + + async function getWorkflowByCharacter(characterId: string) { + const run = [...cache.values()].find( + (item) => item.characterId === characterId, + ); + if (run) return structuredClone(run); + const stored = await store.getByCharacter(characterId); + return stored ? rememberStored(stored) : null; + } + + async function requireWorkflow(runId: WorkflowRun["id"]) { + const run = await load(runId); + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`); + return run; + } + + function subscribe( + runId: WorkflowRun["id"], + listener: (run: WorkflowRun) => void, + ) { + const runListeners = + listeners.get(runId) ?? new Set<(run: WorkflowRun) => void>(); + runListeners.add(listener); + listeners.set(runId, runListeners); + return () => { + runListeners.delete(listener); + if (runListeners.size === 0) listeners.delete(runId); + }; + } + + async function create( + input: CreateWorkflowControllerInput, + ): Promise { + const created = await store.create(input); + rememberStored(created); + return persist( + createWorkflowRunState(input, { + runId: created.id || createId("run"), + createdAt: created.createdAt || now(), }), - ) + ); } - 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 - }) + async function appendAction(runId: WorkflowRun["id"]): Promise { + return persist(appendActionState(await requireWorkflow(runId))); } - 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 }), - ) - }), - ) + async function updateCharacterSetup( + runId: WorkflowRun["id"], + input: CharacterSetupNodeInput, + ): Promise { + return persist( + updateCharacterSetupState(await requireWorkflow(runId), input), + ); } - function submitGeneration( - nodeId: WorkflowNode['id'], - role: WorkflowGenerationRole, - createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + async function acceptUploadedCharacterTemplate( + runId: WorkflowRun["id"], + templateUrl: MediaReference, ): 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 + return persist( + acceptUploadedCharacterTemplateState( + await requireWorkflow(runId), + templateUrl, + ), + ); } - async function performGenerationSubmission( - nodeId: WorkflowNode['id'], - role: WorkflowGenerationRole, - expectedEpoch: number, - createInput: (run: WorkflowRun, node: WorkflowNode) => Parameters[0], + async function nextStep( + runId: WorkflowRun["id"], + spriteSize?: { width: number; height: number }, ): 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 run = requireActiveWorkflow(await requireWorkflow(runId)); + const activeNode = getActiveNode(run); + if (!activeNode) throw new Error("当前 WorkflowRun 没有 active 节点"); + + if (activeNode.type === "character-template") { + return characterTemplateTask.start(runId, { + runId: run.id, + nodeId: activeNode.id, + }); } - - const pendingAttachment = unattachedGenerations.get(key) - if (pendingAttachment?.expectedEpoch === expectedEpoch) { - return attachGeneration(pendingAttachment) + if (activeNode.type !== "character-setup") { + throw new Error(`节点 ${activeNode.type} 尚未进入本轮实现`); } - 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() + if (!spriteSize) throw new Error("推进角色资料节点需要项目精灵图尺寸"); - const attachment = { nodeId, role, expectedEpoch, generation } - unattachedGenerations.set(key, attachment) - return attachGeneration(attachment) + const transitioned = advanceCharacterSetupState(run, spriteSize); + await persist(transitioned.run); + return characterTemplateTask.start(runId, transitioned.target); } - 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() + function resume(runId: WorkflowRun["id"]) { + return load(runId).then((run) => { + if (!run || run.status !== "active") return run; + const node = getActiveNode(run); + return node?.type === "action-first-frame" || node?.type === "action-full-frame" + ? actionGenerationTask.resume(runId) + : characterTemplateTask.resume(runId); + }); } - async function watchGeneration(nodeId: WorkflowNode['id'], taskId: Generation['id']) { - if (interrupted) return - const key = subscriptionKey(nodeId, taskId) - if (subscriptions.has(key)) return + async function interrupt(runId: WorkflowRun["id"]): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") return run; - 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 - } + characterTemplateTask.stop(runId); + actionGenerationTask.stop(runId); + const latest = await requireWorkflow(runId); + if (latest.status !== "active") return latest; + return persist(interruptWorkflowRunState(latest)); } - 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 confirmFirstFrame(runId: WorkflowRun["id"]): Promise { + return persist(confirmFirstFrameState(await requireWorkflow(runId))); } - 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 }) - } + async function startActionFromTemplate( + runId: WorkflowRun["id"], + templateImageUrl: string, + actionDescription?: string, + ): Promise { + const pending = templateActionSubmissions.get(runId); + if (pending) return pending; + const submission = submitActionFromTemplate( + runId, + templateImageUrl, + actionDescription, + ).finally(() => templateActionSubmissions.delete(runId)); + templateActionSubmissions.set(runId, submission); + return submission; + } + + async function submitActionFromTemplate( + runId: WorkflowRun["id"], + templateImageUrl: string, + actionDescription?: string, + ): Promise { + if (!characterApis) throw new Error("角色服务尚未配置,不能开始动作生成"); - 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() || '生成任务失败', - }) + const run = await requireWorkflow(runId); + const initialState = getTemplateActionInputState(run, templateImageUrl); + let character: Awaited> | null = null; + let bound = false; + try { + character = await characterApis.create({ + projectId: run.projectId, + description: "Workflow auto-created character", + referenceImageUrl: templateImageUrl, + }); + + if (character.outfits.length === 0) { + character = await characterApis.update({ + ...character, + outfits: [ + { + id: `outfit-${character.id}-default`, + characterId: character.id, + name: "默认造型", + candidateCharacterTemplates: [], + characterTemplateUrl: templateImageUrl, + baseFrames: [], + actions: [], + }, + ], + }); } - 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 }) - } + const outfitId = character.outfits[0]?.id; + if (!outfitId) throw new Error("角色服务没有返回可用的造型 ID"); - 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, '动作首帧结果格式无效') + const latest = await requireWorkflow(runId); + const latestState = getTemplateActionInputState(latest, templateImageUrl); + if (latestState !== initialState) { + throw new Error("角色母版节点已变更,不能继续提交动作生成"); } - 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} 帧`, - ) + const ready = + latestState === "candidate-active" + ? await persist(confirmFirstFrameState(latest)) + : latest; + const boundRun = await persist({ + ...ready, + characterId: character.id, + outfitId, + }); + bound = true; + const prompt = actionDescription?.trim(); + + return await actionGenerationTask.start(runId, { + type: "character_action", + projectId: boundRun.projectId, + characterId: character.id, + outfitId, + actionType: prompt ? "custom" : "idle", + firstFrameUrl: templateImageUrl, + prompt: prompt || null, + referenceMedia: [templateImageUrl as MediaReference], + numFrames: 32, + }); + } catch (error) { + if (!bound && character) { + try { + await characterApis.remove(character.id); + } catch (cleanupError) { + console.error("[workflow] 清理未绑定角色失败", cleanupError); + } + } + const failedRun = await load(runId); + if (bound && failedRun?.status === "active") { + const activeNode = getActiveNode(failedRun); + if ( + (activeNode?.type === "action-first-frame" || activeNode?.type === "action-full-frame") && + !activeNode.taskId && + !activeNode.submissionId + ) { + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "动作生成失败"; + await persist( + completeActionGenerationState(failedRun, { error: message }), + ); + } + } + throw error; } - return replaceNode(run, { ...node, phase: 'reviewing_animation', error: null }) } - async function resume(): Promise { - interrupted = false - for (const attachment of [...unattachedGenerations.values()]) { - await attachGeneration(attachment) + async function completeActionGeneration( + runId: WorkflowRun["id"], + result: CharacterActionOutput | { error: string }, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") { + console.warn("[completeActionGen] run not active:", run.status); + return run; } - 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() - } + const node = getActiveNode(run); + if (!node || node.status !== "active") { + console.warn( + "[completeActionGen] node not active:", + node?.type, + node?.status, + ); + return run; + } + if ("error" in result) + return persist(completeActionGenerationState(run, result)); - async function interrupt(): Promise { - interrupted = true - stopAllSubscriptions() - return snapshot() + const frameCountError = getCharacterActionFrameCountError(result); + return persist( + completeActionGenerationState( + run, + frameCountError ? { error: frameCountError } : result, + ), + ); } - 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 + function startActionGeneration( + runId: WorkflowRun["id"], + input: CharacterActionGenerationInput, + ) { + return actionGenerationTask.start(runId, input); } - 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 + async function approveReview(runId: WorkflowRun["id"]): Promise { + return persist(approveReviewState(await requireWorkflow(runId))); } - function stopSubscription(key: string) { - const subscription = subscriptions.get(key) - subscriptions.delete(key) - try { - subscription?.stop() - } catch { - // 释放传输连接失败不能反向改变已经持久化的 WorkflowRun。 + async function approveAndPublish( + runId: WorkflowRun["id"], + ): Promise { + if (!characterApis) throw new Error("角色服务尚未配置,不能发布资产"); + const run = await requireWorkflow(runId); + const reviewStep = run.nodes.findLast((node) => node.type === "review"); + const approved = + run.status === "active" && reviewStep?.status === "active" + ? await approveReview(runId) + : run.status === "completed" && reviewStep?.status === "passed" + ? run + : null; + if (!approved) throw new Error("审核节点尚未就绪,不能发布资产"); + + await publishWorkflowRun(characterApis, approved); + return approved; + } + + async function recordActionGenerationTask( + runId: WorkflowRun["id"], + taskId: string, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") { + console.warn("[recordActionTask] run not active:", run.status); + return run; } + return persist(recordActionGenerationTaskState(run, taskId)); } - function stopAllSubscriptions() { - for (const key of [...subscriptions.keys()]) stopSubscription(key) - } - - function dispose() { - interrupted = true - stopAllSubscriptions() + async function recordCharacterRefs( + runId: WorkflowRun["id"], + refs: { characterId: string; outfitId: string }, + ): Promise { + const run = await requireWorkflow(runId); + if (run.status !== "active") { + console.warn("[recordCharacterRefs] run not active:", run.status); + return run; + } + return persist({ + ...run, + characterId: refs.characterId, + outfitId: refs.outfitId, + }); } - function nodeEpoch(nodeId: WorkflowNode['id']) { - return nodeEpochs.get(nodeId) ?? 0 + async function restart( + runId: WorkflowRun["id"], + nodeId: string, + ): Promise { + characterTemplateTask.stop(runId); + actionGenerationTask.stop(runId); + return persist( + restartWorkflowRunState(await requireWorkflow(runId), nodeId), + ); } return { create, getWorkflow, - addAction, - generateCharacter, - confirmCharacter, - generateActionFrame, - confirmActionFrame, - generateAnimation, - approveAction, + getWorkflowByCharacter, + subscribe, + appendAction, + updateCharacterSetup, + acceptUploadedCharacterTemplate, + nextStep, + confirmFirstFrame, + startActionFromTemplate, + completeActionGeneration, + startActionGeneration, + approveReview, + approveAndPublish, + recordActionGenerationTask, + recordCharacterRefs, + restart, resume, interrupt, - restartFromNode, - applyGenerationResult, - getGeneration, - dispose, - } + }; } -function updateNode( +function getTemplateActionInputState( 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, - ), + _templateImageUrl: string, +): "first-frame-active" | "uploaded-template" { + const firstFrameNode = run.nodes.find( + (node) => node.type === "action-first-frame", + ); + const activeNode = getActiveNode(run); + if ( + firstFrameNode?.status === "active" && + activeNode?.type === "action-first-frame" + ) { + return "first-frame-active"; } -} - -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('目标节点的前置依赖尚未完成') + if ( + firstFrameNode?.status === "passed" && + (activeNode?.type === "action-full-frame" || activeNode?.type === "review") + ) { + return "uploaded-template"; } + 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) { +function hasSelectedTemplateUrl( + output: unknown, + templateImageUrl: string, +): boolean { 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)}` + typeof output === "object" && + output !== null && + "selectedImageUrl" in output && + output.selectedImageUrl === templateImageUrl + ); } -function asError(cause: unknown) { - return cause instanceof Error ? cause : new Error(String(cause)) +function createRuntimeId(scope: "run" | "submission") { + const suffix = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return `${scope}-${suffix}`; } diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index d07245c6..fcb6978b 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,9 +1,6 @@ export { createWorkflowController } from './controller' export type { - AddActionInput, - ApplyGenerationResultInput, + CreateWorkflowControllerInput, CreateWorkflowControllerOptions, - GenerateActionOptions, - GenerateCharacterOptions, WorkflowController, } from './controller' diff --git a/frontend/src/features/workflow-controller/store-invariants.test.ts b/frontend/src/features/workflow-controller/store-invariants.test.ts new file mode 100644 index 00000000..37001ac1 --- /dev/null +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Generation, GenerationApis, GenerationInput } from "@/entities"; +import { createWorkflowRunStore } from "@/entities/workflow-run/store"; +import { createWorkflowController } from "."; + +function createHarness() { + const store = createWorkflowRunStore(); + const generationApis: GenerationApis = { + create: vi.fn( + async (input: T) => + ({ + id: "task-1", + projectId: input.projectId, + type: input.type, + status: "pending", + result: null, + error: null, + }) as Generation, + ), + get: vi.fn(async () => { + throw new Error("not used"); + }), + subscribe: vi.fn(() => () => undefined), + }; + return { + store, + controller: createWorkflowController({ + store, + generationApis, + now: () => "2026-07-31T12:00:00.000Z", + }), + }; +} + +describe("workflow persistence invariants", () => { + it("persists a complete frontend node graph immediately after creation", async () => { + const { controller, store } = createHarness(); + const created = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "像素骑士", + }); + + const restored = await store.get(created.id); + expect(restored?.nodes).toHaveLength(5); + expect( + restored?.nodes.filter((node) => node.status === "active"), + ).toHaveLength(1); + }); + + it("persists the existing character references for add_action", async () => { + const { controller, store } = createHarness(); + const created = await controller.create({ + projectId: "project-1", + purpose: "add_action", + prompt: "挥手", + characterId: "character-1", + outfitId: "outfit-1", + characterTemplateUrl: "template.png", + baseFrameUrls: [], + }); + + const restored = await store.get(created.id); + expect(restored).toMatchObject({ + characterId: "character-1", + outfitId: "outfit-1", + }); + expect( + restored?.nodes.find((node) => node.type === "action-generation")?.status, + ).toBe("active"); + }); + + it("persists interruption without clearing the active node", async () => { + const { controller, store } = createHarness(); + const created = await controller.create({ + projectId: "project-1", + purpose: "create_character", + }); + await controller.interrupt(created.id); + + const restored = await store.get(created.id); + expect(restored?.status).toBe("interrupted"); + expect( + restored?.nodes.filter((node) => node.status === "active"), + ).toHaveLength(1); + }); +}); diff --git a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts new file mode 100644 index 00000000..2a5cddf6 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, +} from "@/entities"; +import { createWorkflowController } from "."; + +describe("WorkflowRun first vertical slice", () => { + it("runs character setup through a completed character-template task", async () => { + const store = createWorkflowRunStore(); + const taskChannel: { listener?: (event: GenerationEvent) => void } = {}; + + const createGeneration: GenerationApis["create"] = async < + T extends GenerationInput, + >( + input: T, + ) => + ({ + id: "task-character-template-1", + projectId: input.projectId, + type: input.type, + status: "pending", + result: null, + error: null, + }) as Generation; + + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error("not used in this slice"); + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + taskChannel.listener = onEvent; + onEvent({ + taskId, + type: "character_image", + status: "pending", + error: null, + result: null, + }); + return () => { + delete taskChannel.listener; + }; + }), + }; + const controller = createWorkflowController({ + store, + generationApis, + createId: () => "submission-1", + now: () => "2026-07-30T12:00:00.000Z", + }); + + const created = await controller.create({ + projectId: "project-1", + purpose: "create_character", + prompt: "像素骑士", + }); + + await controller.nextStep(created.id, { width: 64, height: 64 }); + + const inFlight = await store.get(created.id); + expect( + inFlight?.nodes.find((node) => node.type === "character-template"), + ).toMatchObject({ + status: "active", + taskId: "task-character-template-1", + }); + + const taskListener = taskChannel.listener; + if (!taskListener) + throw new Error("expected the task subscription to be active"); + taskListener({ + taskId: "task-character-template-1", + type: "character_image", + status: "completed", + error: null, + result: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + await vi.waitFor(async () => { + const completed = await store.get(created.id); + expect( + completed?.nodes.find((node) => node.type === "character-template"), + ).toMatchObject({ + status: "passed", + taskId: null, + output: { + type: "character_image", + imageUrls: ["https://example.com/knight.png"], + }, + }); + expect( + completed?.nodes.find((node) => node.type === "action-first-frame"), + ).toMatchObject({ status: "active" }); + }); + }); +}); diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts new file mode 100644 index 00000000..7d7482ae --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; + +import type { MediaReference, WorkflowRun } from "@/entities"; + +import { + acceptUploadedCharacterTemplateState, + advanceCharacterSetupState, + appendActionState, + approveReviewState, + beginActionGenerationState, + completeActionGenerationState, + createWorkflowRunState, + restartWorkflowRunState, + updateCharacterSetupState, +} from "./workflow-state"; + +const CREATED_AT = "2026-07-31T02:40:00.000Z"; + +function createRun() { + return createWorkflowRunState( + { + projectId: "project-1", + purpose: "create_character", + prompt: " pixel knight ", + }, + { runId: "run-1", createdAt: CREATED_AT }, + ); +} + +function readyForReview(): WorkflowRun { + const run = createRun(); + return { + ...run, + characterId: "character-1", + outfitId: "outfit-1", + generationStatus: "completed", + nodes: run.nodes.map((node) => ({ + ...node, + status: + node.type === "review" ? ("active" as const) : ("passed" as const), + })), + }; +} + +describe("workflow state transitions", () => { + it("creates one WorkflowRun with the five initial nodes", () => { + const run = createRun(); + + expect(run).toMatchObject({ + id: "run-1", + projectId: "project-1", + status: "active", + prompt: "pixel knight", + createdAt: CREATED_AT, + }); + expect(run.nodes.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: "character-setup", status: "active" }, + { type: "character-template", status: "locked" }, + { type: "action-first-frame", status: "locked" }, + { type: "action-generation", status: "locked" }, + { type: "review", status: "locked" }, + ]); + }); + + it("starts add_action directly at action generation for the existing outfit", () => { + const run = createWorkflowRunState( + { + projectId: "project-1", + purpose: "add_action", + prompt: "挥手打招呼", + characterId: "character-1", + outfitId: "outfit-1", + characterTemplateUrl: "https://example.com/template.png", + baseFrameUrls: [], + }, + { runId: "run-action-1", createdAt: CREATED_AT }, + ); + + expect(run.nodes.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: "character-setup", status: "passed" }, + { type: "character-template", status: "passed" }, + { type: "action-first-frame", status: "passed" }, + { type: "action-generation", status: "active" }, + { type: "review", status: "locked" }, + ]); + }); + + it("normalizes setup and advances with a frozen generation input", () => { + const updated = updateCharacterSetupState(createRun(), { + description: " revised knight ", + referenceMedia: [], + }); + const transitioned = advanceCharacterSetupState(updated, { + width: 64, + height: 64, + }); + + expect(transitioned.target).toEqual({ + runId: "run-1", + nodeId: "run-1:character-template", + }); + expect(transitioned.run.nodes[1]).toMatchObject({ + type: "character-template", + status: "active", + input: { + type: "character_image", + projectId: "project-1", + prompt: "revised knight", + spriteWidth: 64, + spriteHeight: 64, + }, + }); + }); + + it("accepts an uploaded template without fabricating a generation task", () => { + const accepted = acceptUploadedCharacterTemplateState( + createRun(), + "https://cdn.example.com/uploaded.png" as MediaReference, + ); + + expect(accepted.nodes[1]).toMatchObject({ + type: "character-template", + status: "passed", + taskId: null, + output: { + type: "character_image", + imageUrls: ["https://cdn.example.com/uploaded.png"], + }, + }); + expect(accepted.nodes[2]?.output).toEqual({ + selectedImageUrl: "https://cdn.example.com/uploaded.png", + }); + expect(accepted.nodes[3]?.status).toBe("active"); + }); + + it("completes the run when the active review is approved", () => { + const completed = approveReviewState(readyForReview()); + + expect(completed.status).toBe("completed"); + expect(completed.nodes.every((node) => node.status === "passed")).toBe( + true, + ); + }); + + it("reopens the same run and appends a unique action/review pair", () => { + const appended = appendActionState(approveReviewState(readyForReview())); + + expect(appended.id).toBe("run-1"); + expect(appended.status).toBe("active"); + expect( + appended.nodes.slice(-2).map(({ type, status }) => ({ type, status })), + ).toEqual([ + { type: "action-generation", status: "active" }, + { type: "review", status: "locked" }, + ]); + expect(new Set(appended.nodes.map((node) => node.id)).size).toBe( + appended.nodes.length, + ); + }); + + it("records a 32-frame action without overwriting earlier action nodes", () => { + const appended = appendActionState(approveReviewState(readyForReview())); + const input = { + type: "character_action" as const, + projectId: "project-1", + characterId: "character-1", + outfitId: "outfit-1", + actionType: "custom" as const, + firstFrameUrl: "template.png", + prompt: "挥手", + referenceMedia: ["template.png" as MediaReference], + numFrames: 32, + }; + const submitting = beginActionGenerationState( + appended, + input, + "submission-2", + ); + const generated = completeActionGenerationState(submitting, { + type: "character_action", + actionType: "custom", + frames: Array.from({ length: 32 }, (_, index) => ({ + index, + imageUrl: `wave-${index}.png`, + durationMs: null, + })), + }); + + expect( + generated.nodes.filter((node) => node.type === "action-generation"), + ).toHaveLength(2); + expect(generated.nodes.at(-2)).toMatchObject({ status: "passed" }); + expect(generated.nodes.at(-1)).toMatchObject({ + type: "review", + status: "active", + }); + }); + + it("restarts a passed node in place and clears its downstream results", () => { + const run = readyForReview(); + const restarted = restartWorkflowRunState(run, "run-1:character-template"); + + expect(restarted.id).toBe(run.id); + expect(restarted.nodes[1]).toMatchObject({ + status: "active", + output: null, + }); + expect( + restarted.nodes.slice(2).every((node) => node.status === "locked"), + ).toBe(true); + }); +}); diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts new file mode 100644 index 00000000..ab55507a --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,467 @@ +import { + WORKFLOW_NODE_ORDER, + type CharacterSetupNodeInput, + type CharacterImageGenerationInput, + type CharacterActionGenerationInput, + type CharacterActionOutput, + type CreateWorkflowRunInput, + type MediaReference, + type WorkflowRun, + type WorkflowNode, + type WorkflowNodeStatus, + type WorkflowNodeType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = CreateWorkflowRunInput + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + createdAt: string +} + +export interface WorkflowNodeTarget { + runId: WorkflowRun['id'] + nodeId: WorkflowNode['id'] +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + const nodes = createInitialNodes(input, runId, prompt) + + return { + id: runId, + projectId: input.projectId, + characterId: input.purpose === 'add_action' ? input.characterId : null, + outfitId: input.purpose === 'add_action' ? input.outfitId : null, + purpose: input.purpose, + status: 'active', + nodes, + generationStatus: 'not_started', + exportStatus: 'not_exported', + prompt, + createdAt, + } +} + +function createInitialNodes( + input: CreateWorkflowRunStateInput, + runId: string, + prompt: string | null, +): WorkflowNode[] { + const nodes = WORKFLOW_NODE_ORDER.map((type, index) => + createInitialNode(type, runId, index, prompt), + ) + if (input.purpose === 'create_character') return nodes + + return nodes.map((node) => { + if (node.type === 'character-setup') { + return { + ...node, + status: 'passed' as const, + input: { + description: prompt ?? '为已有角色添加动作', + referenceMedia: [], + }, + } + } + if (node.type === 'character-template') { + return { + ...node, + status: 'passed' as const, + output: { + type: 'character_image' as const, + imageUrls: [input.characterTemplateUrl], + }, + } + } + if (node.type === 'action-first-frame') { + return { ...node, status: 'passed' as const } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'active' as const } + } + return node + }) +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRun { + return run +} + +export function getActiveNode(run: WorkflowRun): WorkflowNode | null { + return run.nodes.find((node) => node.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowNode( + run: WorkflowRun, + nodeId: WorkflowNode['id'], + update: (node: WorkflowNode) => WorkflowNode, +): WorkflowRun { + return { + ...run, + nodes: run.nodes.map((node) => (node.id === nodeId ? update(node) : node)), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupNodeInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const node = run.nodes.find((item) => item.type === 'character-setup') + if (!node || node.type !== 'character-setup' || node.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料节点') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowNode(run, node.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +export function acceptUploadedCharacterTemplateState( + workflow: WorkflowRun, + templateUrl: MediaReference, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const activeNode = getActiveNode(run) + if (!activeNode || activeNode.type !== 'character-setup') { + throw new Error('当前只能在角色资料节点采用上传母版') + } + + const normalizedUrl = String(templateUrl).trim() + if (!normalizedUrl) throw new Error('上传角色母版引用不能为空') + + return { + ...run, + nodes: run.nodes.map((node) => { + if (node.type === 'character-setup') { + return { + ...node, + status: 'passed' as const, + input: { + description: '使用上传角色母版', + referenceMedia: [normalizedUrl as MediaReference], + }, + } + } + if (node.type === 'character-template') { + return { + ...node, + status: 'passed' as const, + input: null, + output: { + type: 'character_image' as const, + imageUrls: [normalizedUrl], + }, + } + } + if (node.type === 'action-first-frame') { + return { ...node, status: 'passed' as const } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'active' as const } + } + return node + }), + } +} + +export function advanceCharacterSetupState( + workflow: WorkflowRun, + spriteSize: { width: number; height: number }, +): { + run: WorkflowRun + target: WorkflowNodeTarget +} { + const run = requireActiveWorkflow(workflow) + const activeNode = getActiveNode(run) + if (!activeNode) throw new Error('当前 WorkflowRun 没有 active 节点') + if (activeNode.type !== 'character-setup') { + throw new Error(`当前节点不是角色资料:${activeNode.type}`) + } + if (!activeNode.input) throw new Error('请先填写角色资料') + + const templateNode = run.nodes.find((node) => node.type === 'character-template') + if (!templateNode) throw new Error('WorkflowRun 缺少 character-template 节点') + + const generationInput: CharacterImageGenerationInput = { + type: 'character_image', + projectId: run.projectId, + prompt: activeNode.input.description, + referenceMedia: activeNode.input.referenceMedia, + spriteWidth: spriteSize.width, + spriteHeight: spriteSize.height, + } + + return { + run: { + ...run, + generationStatus: 'in_progress' as const, + nodes: run.nodes.map((node) => { + if (node.id === activeNode.id) return { ...node, status: 'passed' as const } + if (node.id !== templateNode.id || node.type !== 'character-template') return node + return { + ...node, + status: 'active' as const, + input: generationInput, + } + }), + }, + target: { runId: run.id, nodeId: templateNode.id }, + } +} + +export function confirmFirstFrameState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + const firstFrameNode = run.nodes.find((node) => node.type === 'action-first-frame') + if (!firstFrameNode || firstFrameNode.status !== 'active') { + throw new Error('当前只能确认处于 active 状态的首帧节点') + } + + return { + ...run, + nodes: run.nodes.map((node) => { + if (node.id === firstFrameNode.id && node.type === 'action-first-frame') { + return { ...node, status: 'passed' as const } + } + if (node.type === 'action-full-frame') { + return { ...node, status: 'active' as const } + } + return node + }), + } +} + +export function completeActionGenerationState( + run: WorkflowRun, + result: CharacterActionOutput | { error: string }, +): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可完成动作生成:${run.status}`) + const actionNode = getActiveNode(run) + if ( + !actionNode || + (actionNode.type !== 'action-first-frame' && actionNode.type !== 'action-full-frame') + ) { + throw new Error('当前只能完成处于 active 状态的动作生成节点') + } + + const failed = result !== null && typeof result === 'object' && 'error' in result + const actionIndex = run.nodes.findIndex((node) => node.id === actionNode.id) + + const updated = replaceWorkflowNode(run, actionNode.id, (current) => { + if (current.type !== 'action-first-frame' && current.type !== 'action-full-frame') + return current + return { + ...current, + status: failed ? ('failed' as const) : ('passed' as const), + output: failed ? null : result, + error: failed ? String((result as { error: string }).error) : null, + taskId: null, + submissionId: null, + } + }) + + // 找到下一个节点并激活 + const nextNode = updated.nodes[actionIndex + 1] + return { + ...updated, + nodes: updated.nodes.map((node) => { + if (failed || !nextNode || node.id !== nextNode.id) return node + return { ...node, status: 'active' as const } + }), + status: failed ? ('failed' as const) : updated.status, + generationStatus: failed ? ('failed' as const) : ('completed' as const), + } +} + +export function approveReviewState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可审核:${run.status}`) + const reviewNode = getActiveNode(run) + if (!reviewNode || reviewNode.type !== 'review') { + throw new Error('当前只能通过处于 active 状态的审核节点') + } + + return { + ...run, + status: 'completed', + nodes: run.nodes.map((node) => + node.id === reviewNode.id ? { ...node, status: 'passed' as const, error: null } : node, + ), + } +} + +export function appendActionState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'completed') throw new Error('只能给已完成的 WorkflowRun 追加动作') + if (!run.characterId || !run.outfitId) throw new Error('WorkflowRun 尚未绑定角色与造型') + + const actionNumber = run.nodes.filter((node) => node.type === 'action-full-frame').length + 1 + const actionNode = { + ...createInitialNode('action-full-frame', run.id, run.nodes.length, null), + id: `${run.id}:action-full-frame:${actionNumber}`, + status: 'active' as const, + } + const reviewNode = { + ...createInitialNode('review', run.id, run.nodes.length + 1, null), + id: `${run.id}:review:${actionNumber}`, + status: 'locked' as const, + } + + return { + ...run, + status: 'active', + generationStatus: 'not_started', + exportStatus: 'not_exported', + nodes: [...run.nodes, actionNode, reviewNode], + } +} + +export function beginActionGenerationState( + run: WorkflowRun, + input: CharacterActionGenerationInput, + submissionId: string, +): WorkflowRun { + const activeRun = requireActiveWorkflow(run) + const actionNode = getActiveNode(activeRun) + if ( + !actionNode || + (actionNode.type !== 'action-first-frame' && actionNode.type !== 'action-full-frame') || + actionNode.taskId + ) { + throw new Error('当前动作生成节点不可重复提交') + } + return replaceWorkflowNode(activeRun, actionNode.id, (current) => { + if (current.type !== 'action-first-frame' && current.type !== 'action-full-frame') + return current + return { ...current, input, submissionId, error: null } + }) +} + +export function recordActionGenerationTaskState( + run: WorkflowRun, + taskId: string, + input?: CharacterActionGenerationInput, +): WorkflowRun { + if (run.status !== 'active' && run.status !== 'interrupted') { + throw new Error(`WorkflowRun 当前不可记录任务:${run.status}`) + } + const actionNode = getActiveNode(run) + if ( + !actionNode || + (actionNode.type !== 'action-first-frame' && actionNode.type !== 'action-full-frame') + ) { + throw new Error('当前只能为 active 状态的动作生成节点记录任务') + } + return replaceWorkflowNode(run, actionNode.id, (current) => { + if (current.type !== 'action-first-frame' && current.type !== 'action-full-frame') + return current + return { ...current, taskId, input: input ?? current.input, submissionId: null } + }) +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +export function restartWorkflowRunState( + run: WorkflowRun, + restartNodeId: WorkflowNode['id'], +): WorkflowRun { + const restartIndex = run.nodes.findIndex((node) => node.id === restartNodeId) + const restartNode = run.nodes[restartIndex] + if (!restartNode || restartNode.status !== 'passed') { + throw new Error('只能从已通过的节点重新开始') + } + + const retainedNodeCount = + restartIndex < 3 + ? WORKFLOW_NODE_ORDER.length + : restartNode.type === 'action-full-frame' + ? restartIndex + 2 + : restartIndex + 1 + + const nodes = run.nodes.slice(0, retainedNodeCount).map((node, index) => { + if (index < restartIndex) { + return { ...structuredClone(node), status: 'passed' as const, taskId: null, submissionId: null, error: null } + } + if (index === restartIndex) { + return { ...structuredClone(node), status: 'active' as const, taskId: null, submissionId: null, error: null, output: null } as WorkflowNode + } + return lockFreshNode(node.type, run.id, index, run.prompt) + }) + + return { + ...run, + status: 'active', + nodes, + generationStatus: 'not_started', + exportStatus: 'not_exported', + } +} + +function lockFreshNode( + type: WorkflowNodeType, + runId: WorkflowRun['id'], + index: number, + prompt: string | null, +): WorkflowNode { + return { + ...createInitialNode(type, runId, index, prompt), + status: 'locked', + } +} + +function createInitialNode( + type: WorkflowNodeType, + runId: string, + index: number, + prompt: string | null, +): WorkflowNode { + const status: WorkflowNodeStatus = index === 0 ? 'active' : 'locked' + const base = { + id: createNodeId(runId, type, index), + status, + taskId: null, + submissionId: null, + error: null, + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { ...base, type, input: null, output: null } + } + if (type === 'action-first-frame' || type === 'action-full-frame') { + return { ...base, type, input: null, output: null } + } + return { ...base, type, input: null, output: null } as WorkflowNode +} + +function createNodeId(runId: string, type: WorkflowNodeType, index: number): string { + if (index < WORKFLOW_NODE_ORDER.length) return `${runId}:${type}` + const actionNumber = Math.floor((index - WORKFLOW_NODE_ORDER.length) / 2) + 1 + return `${runId}:${type}:${actionNumber}` +} diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx new file mode 100644 index 00000000..a26a0df9 --- /dev/null +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; + +import type { WorkflowRun } from "@/entities"; +import { createWorkflowRunState } from "@/features/workflow-controller/workflow-state"; +import { QuickStartPage } from "."; +import type { QuickStartService } from "./service"; + +afterEach(cleanup); + +function runFixture(): WorkflowRun { + return createWorkflowRunState( + { projectId: "project-1", purpose: "create_character", prompt: "像素骑士" }, + { runId: "run-1", createdAt: "2026-08-07T00:00:00.000Z" }, + ); +} + +function service( + overrides: Partial = {}, +): QuickStartService { + const run = runFixture(); + return { + unavailableReason: null, + start: vi.fn(async () => run), + startWithUploadedTemplate: vi.fn(async () => run), + continueWithUploadedTemplate: vi.fn(async () => run), + startAction: vi.fn(async () => run), + getWorkflow: vi.fn(() => null), + subscribe: vi.fn(() => () => undefined), + resume: vi.fn(async () => run), + interrupt: vi.fn(async () => run), + confirmCandidate: vi.fn(async () => run), + approveReview: vi.fn(async () => run), + getCharacterInfo: vi.fn(() => null), + resolveCharacterInfo: vi.fn(async () => null), + ...overrides, + }; +} + +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} + +function renderPage(testService: QuickStartService, entry = "/quick-start") { + return render( + + + + + + + } + /> + + + + + } + /> + + , + ); +} + +describe("QuickStartPage", () => { + it("starts from natural language and stays in the Quick Start interface", async () => { + const testService = service(); + renderPage(testService); + + fireEvent.change(screen.getByLabelText("创作指令"), { + target: { value: "像素骑士" }, + }); + fireEvent.click(screen.getByRole("button", { name: "开始生成" })); + + await waitFor(() => { + expect(screen.getByLabelText("当前路径").textContent).toBe( + "/quick-start/run-1", + ); + }); + expect(testService.start).toHaveBeenCalledWith("像素骑士"); + }); + + it("restores a run asynchronously when the page cache starts empty", async () => { + const testService = service(); + renderPage(testService, "/quick-start/run-1"); + + expect(await screen.findByText("像素骑士")).toBeTruthy(); + expect(testService.resume).toHaveBeenCalledWith("run-1"); + }); + + it("shows the configured unavailable reason instead of starting a fake run", () => { + const testService = service({ unavailableReason: "生成服务尚未配置" }); + renderPage(testService); + + expect(screen.getByText("生成服务尚未配置")).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "开始生成" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); +}); diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 10e1be5b..1e6e8baf 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,13 +1,953 @@ -import { PageContainer } from '@/shared/ui' +import { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, + type FormEvent, +} from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router"; + +import { + type CharacterTemplateWorkflowNode, + type WorkflowRevision, + type WorkflowRun, + type WorkflowNode, + type WorkflowNodeType, +} from "@/entities"; +import { buildPlaytestPath, buildPublishedActionId } from "@/features/publish"; +import { + unavailableQuickStartService, + type QuickStartService, +} from "./service"; + +export type { + CreateQuickStartServiceOptions, + PrepareQuickStartProject, + QuickStartService, +} from "./service"; + +const STEP_LABELS: Record = { + "character-setup": "角色设定", + "character-template": "角色图", + "action-first-frame": "候选选择", + "action-generation": "动作生成", + review: "审核", +}; + +const EXAMPLES = [ + { + label: "像素守夜人", + prompt: "一位提着风灯、披深色斗篷的像素守夜人", + }, + { + label: "轻装信使", + prompt: "轻装信使,侧视像素风,轮廓清晰,动作轻快", + }, +] as const; + +export interface QuickStartPageProps { + /** + * 页面测试与后续生产组合可以注入同一份服务实例。 + * 默认实现明确不可用,直到真实 Project / Character / Generation 实现到位。 + */ + service?: QuickStartService; +} + +/** Quick Start 独立完成 AI 入口;它不跳转 Workflow Editor。 */ +export function QuickStartPage({ + service = unavailableQuickStartService, +}: QuickStartPageProps) { + const { runId } = useParams(); + const [searchParams] = useSearchParams(); + const characterId = searchParams.get("characterId"); + const outfitId = searchParams.get("outfitId"); + + return runId ? ( + + ) : characterId && outfitId ? ( + + ) : ( + + ); +} + +function QuickStartActionInput({ + service, + target, +}: { + service: QuickStartService; + target: { characterId: string; outfitId: string }; +}) { + const navigate = useNavigate(); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function submit(event: FormEvent) { + event.preventDefault(); + const prompt = description.trim(); + if (!prompt || submitting || service.unavailableReason) return; + setSubmitting(true); + setError(null); + try { + const run = await service.startAction(target, prompt); + navigate(`/quick-start/${encodeURIComponent(run.id)}`); + } catch (cause) { + setError(errorMessage(cause, "创建动作失败,请稍后重试")); + } finally { + setSubmitting(false); + } + } -/** 快速开始。 */ -export function QuickStartPage() { return ( - -
    -

    快速开始

    -

    本次只提交模块划分与接口,页面实现进后续 PR。

    +
    + + ← 返回当前 Playtest + +
    +

    + ADD ACTION +

    +

    给当前角色增加动作

    +

    + 新动作会追加到角色 {target.characterId}{" "} + 的当前造型,不会新建角色或覆盖已有动作。 +

    +
    +
    + + + + `; + } else if (isPassed) { + bodyHtml = ` +
    角色设定${statusLabel}
    +

    已提交角色设定

    + `; + } else { + bodyHtml = `
    角色设定${statusLabel}
    `; + } + break; + + case "character-template": + if (isActive) { + bodyHtml = ` +
    生成角色图生成中…
    +
    ${Array.from( + { length: 81 }, + (_, i) => { + const x = (i % 9) - 4, + y = Math.floor(i / 9) - 4; + const ring = Math.max(Math.abs(x), Math.abs(y)); + return ``; + }, + ).join("")}
    + 正在生成 ${CHARACTER_CANDIDATE_COUNT} 张候选母版… + `; + } else if (isPassed) { + bodyHtml = ` +
    生成角色图${statusLabel}
    +

    角色图已生成,下一步确认候选。

    + `; + } else { + bodyHtml = `
    生成角色图${statusLabel}
    `; + } + break; + + case "action-first-frame": + if (isActive) { + const templateNode = getCurrentRevision(run)?.nodes.find( + (item) => item.type === "character-template", + ); + const candidates = + templateNode?.type === "character-template" + ? (templateNode.output?.imageUrls ?? []).slice( + 0, + CHARACTER_CANDIDATE_COUNT, + ) + : []; + bodyHtml = ` +
    确认候选${statusLabel}
    +
    + ${CHARACTER_CANDIDATE_COUNT} 选 1 + 选择一张作为角色母版,确认前可以随时切换。 +
    +
    + ${candidates.map((candidateUrl, index) => ``).join("")} +
    + ${candidates.length > 0 ? '' : '

    生成结果中没有可用候选。

    '} + `; + } else if (isPassed) { + bodyHtml = ` +
    确认候选${statusLabel}
    +

    已确认身份母版。

    + `; + } else { + bodyHtml = `
    确认候选${statusLabel}
    `; + } + break; + + case "action-full-frame": + if (isActive) { + bodyHtml = ` +
    动作生成生成中…
    +
    正在生成 ${CHARACTER_ACTION_FRAME_COUNT} 帧动作动画…
    +
    + ${Array.from({ length: CHARACTER_ACTION_FRAME_COUNT }, (_, i) => `${String(i + 1).padStart(2, "0")}`).join("")} +
    + `; + } else if (isPassed) { + const frames = + node.type === "action-full-frame" + ? (node.output?.frames ?? []) + : []; + bodyHtml = ` +
    动作生成${statusLabel}
    +
    + ${frames.map((frame, i) => `动作第 ${i + 1} 帧${String(i + 1).padStart(2, "0")}`).join("")} +
    +

    动作帧已生成,进入审核。

    + `; + } else { + bodyHtml = `
    动作生成${statusLabel}
    `; + } + break; + + case "review": { + if (isActive) { + bodyHtml = ` +
    审核${statusLabel}
    +

    检查所有动作是否符合预期。

    + + `; + } else if (isPassed) { + bodyHtml = ` +
    审核已完成
    +

    生成结果已经保存,下一步由你决定。

    + `; + } else { + bodyHtml = `
    审核${statusLabel}
    `; + } + break; + } + + default: + bodyHtml = `
    ${meta.title}${statusLabel}
    `; + } + } + + const hasInput = node.type !== "character-setup"; + const hasOutput = node.type !== "review"; + const outputEnabled = isPassed || isActive; + + return ` +
    + ${hasInput ? '' : ""} +
    + ${meta.eyebrow}

    ${meta.title}

    + +
    +
    ${bodyHtml}
    + ${hasInput ? `` : ""} + ${hasOutput ? `` : ""} +
    + `; +} + +export function WorkflowCanvas({ + controller, + run, + unavailableReason, + onStepAction, +}: WorkflowCanvasProps) { + const rootRef = useRef(null); + const revision = getCurrentRevision(run); + + // 连线只表达 WorkflowNode 的先后关系,不再作为第二套业务状态门控按钮。 + useEffect(() => { + if (!revision) return; + controller.renderWires(); + }, [controller, revision]); + + // 绑定交互事件 + useEffect(() => { + if (!rootRef.current || !revision) return; + controller.attach(rootRef.current); + const root = rootRef.current; + const form = root.querySelector("#characterSetupForm"); + const handleSetupSubmit = (event: Event) => { + event.preventDefault(); + const description = new FormData(form!).get("description"); + const fileInput = form?.elements.namedItem( + "templateFile", + ) as HTMLInputElement | null; + const file = fileInput?.files?.[0]; + if (typeof description === "string" && (description.trim() || file)) { + onStepAction?.("character-setup", "submit", { + description: description.trim(), + ...(file ? { file } : {}), + }); + } + }; + form?.addEventListener("submit", handleSetupSubmit); + + const candidateButtons = Array.from( + root.querySelectorAll("[data-select-candidate]"), + ); + const confirmCandidate = root.querySelector( + "[data-confirm-candidate]", + ); + const selectCandidate = (event: Event) => { + candidateButtons.forEach((button) => { + button.classList.remove("is-selected"); + button.setAttribute("aria-pressed", "false"); + }); + const selected = event.currentTarget as HTMLButtonElement; + selected.classList.add("is-selected"); + selected.setAttribute("aria-pressed", "true"); + if (confirmCandidate) { + confirmCandidate.dataset.candidateUrl = selected.dataset.candidateUrl; + const selectedIndex = Number(selected.dataset.selectCandidate) + 1; + confirmCandidate.textContent = `使用候选 ${String(selectedIndex).padStart(2, "0")}`; + confirmCandidate.disabled = false; + } + }; + candidateButtons.forEach((button) => + button.addEventListener("click", selectCandidate), + ); + const handleCandidateConfirm = () => { + const selectedImageUrl = confirmCandidate?.dataset.candidateUrl; + if (selectedImageUrl) + onStepAction?.("action-first-frame", "confirm", { selectedImageUrl }); + }; + confirmCandidate?.addEventListener("click", handleCandidateConfirm); + + const approveReview = root.querySelector( + "[data-approve-review]", + ); + const handleReviewApprove = () => onStepAction?.("review", "approve"); + approveReview?.addEventListener("click", handleReviewApprove); + + return () => { + form?.removeEventListener("submit", handleSetupSubmit); + candidateButtons.forEach((button) => + button.removeEventListener("click", selectCandidate), + ); + confirmCandidate?.removeEventListener("click", handleCandidateConfirm); + approveReview?.removeEventListener("click", handleReviewApprove); + controller.detach(); + }; + }, [controller, revision, onStepAction]); + + if (!revision) { + return ( +
    +
    +
    + + 无法加载工作流 + 找不到当前版本 + +
    +
    +
    + ); + } + + const visibleSteps = revision.nodes + .map((node, index) => ({ node, index })) + .filter(({ node, index }) => { + if (node.status !== "locked") return true; + return index <= 1; // 只显示前两步(character-setup 和 character-template) + }); + + return ( +
    +
    + +
    + ); +} + +function getHintText(revision: WorkflowRevision): string { + const activeNode = revision.nodes.find((s) => s.status === "active"); + if (!activeNode) return "所有节点已完成"; + const meta = NODE_TITLES[activeNode.type]; + return meta ? `当前:${meta.title}` : `当前:${activeNode.type}`; +} diff --git a/frontend/src/pages/workflow-editor/workflow-editor.css b/frontend/src/pages/workflow-editor/workflow-editor.css new file mode 100644 index 00000000..717c1f54 --- /dev/null +++ b/frontend/src/pages/workflow-editor/workflow-editor.css @@ -0,0 +1,1285 @@ +/* 工作流编辑器样式 */ +:root { + --wf-white: #e8ebe7; + --wf-surface: #fbfcf8; + --wf-ink: #1b211d; + --wf-muted: #687069; + --wf-line: #cbd1cb; + --wf-accent: #263f2d; + --wf-accent-soft: #e4ebe2; + --wf-choice: #c65335; + --wf-preview: #245c78; +} + +.workflow-app { + min-height: 100vh; + background: var(--wf-white); +} + +/* Studio Bar */ +.studio-bar { + position: sticky; + z-index: 20; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + padding: 0 clamp(16px, 3vw, 48px); + border-bottom: 1px solid rgba(31, 43, 34, 0.1); + background: rgba(223, 227, 223, 0.88); + backdrop-filter: blur(18px); +} +.studio-bar__left, +.studio-bar__right { + display: flex; + align-items: center; + gap: 12px; +} +.studio-bar__brand { + display: inline-flex; + gap: 8px; + align-items: center; + color: var(--wf-ink); + text-decoration: none; +} +.studio-bar__brand .product-brand__mark { + display: block; + width: 28px; + height: 28px; + background: var(--wf-ink); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231b1b1b'/%3E%3Cpath d='M16 5 27 16 16 27 5 16Z' fill='%23ff6b35'/%3E%3Ccircle cx='16' cy='16' r='4' fill='%23fff4e8'/%3E%3C/svg%3E") + center/contain no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%231b1b1b'/%3E%3Cpath d='M16 5 27 16 16 27 5 16Z' fill='%23ff6b35'/%3E%3Ccircle cx='16' cy='16' r='4' fill='%23fff4e8'/%3E%3C/svg%3E") + center/contain no-repeat; +} +.studio-bar__brand b { + font-family: Georgia, 'Times New Roman', serif; + font-size: 16px; + font-weight: 700; +} +.studio-bar__project { + display: flex; + flex-direction: column; + gap: 1px; + padding-left: 12px; + border-left: 1px solid var(--wf-line); +} +.studio-bar__project b { + font-size: 12px; + font-weight: 600; +} +.studio-bar__project small { + font-size: 10px; + color: var(--wf-muted); +} +.studio-bar__nav { + display: flex; + gap: 4px; +} +.studio-bar__nav a { + display: inline-flex; + min-height: 32px; + align-items: center; + padding: 0 10px; + border-radius: 8px; + color: #5e635d; + font-size: 11px; + font-weight: 600; + text-decoration: none; +} +.studio-bar__nav a:hover { + background: rgba(255, 255, 255, 0.6); +} +.studio-bar__nav a.is-active { + color: var(--wf-accent); + background: var(--wf-accent-soft); +} +.studio-bar__actions { + display: flex; + gap: 4px; +} +.studio-bar__actions button { + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--wf-line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.6); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +/* Production Canvas */ +.production-canvas-workspace { + position: relative; + height: calc(100svh - 52px); + min-height: 660px; + overflow: hidden; + background: var(--wf-white); +} + +/* Project Setup */ +.project-setup { + display: grid; + width: 100%; + min-height: 100%; + place-items: center; + padding: 40px; + background: + radial-gradient(circle at 78% 18%, rgba(88, 111, 94, 0.08), transparent 28%), + linear-gradient(150deg, #f8f7f3, #ecece7 74%); +} +.project-setup__form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + max-width: 560px; + width: 100%; +} +.project-setup__form-head { + grid-column: 1 / -1; +} +.project-setup__form-head h2 { + margin: 0; + font: + 500 32px/1.1 Georgia, + 'Songti SC', + serif; +} +.project-setup__wide { + grid-column: 1 / -1; +} +.project-setup__form label { + display: grid; + gap: 6px; +} +.project-setup__form label span { + font-size: 11px; + font-weight: 600; +} +.project-setup__form input, +.project-setup__form select, +.project-setup__form textarea { + padding: 10px 12px; + border: 1px solid rgba(27, 38, 30, 0.15); + border-radius: 10px; + background: rgba(255, 255, 255, 0.7); + font-size: 13px; + font-family: inherit; +} +.project-setup__form footer { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + padding-top: 8px; +} +.button--primary { + padding: 12px 24px; + border: 1px solid var(--wf-accent); + border-radius: 12px; + background: var(--wf-accent); + color: #f2f5f1; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.button--primary:hover { + background: #1d3025; +} + +/* Setup Messages */ +.setup-notice { + margin: 12px 40px; + padding: 12px 16px; + border: 1px solid #c7a967; + border-radius: 12px; + background: #f4eddc; + color: #67552e; + font-size: 13px; +} +.setup-error { + margin: 12px 40px; + padding: 12px 16px; + border-radius: 12px; + background: #311b19; + color: #ffd3cc; + font-size: 13px; +} +.setup-loading { + margin: 12px 40px; + color: #687069; + font-size: 13px; +} + +/* Error View */ +.error-view { + padding: 80px 40px; +} +.error-view .overline { + font-family: ui-monospace, monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.16em; + color: #687069; +} +.error-view h1 { + margin: 16px 0 12px; + font: + 500 40px/1.1 Georgia, + 'Songti SC', + serif; +} +.error-view p { + color: #687069; + font-size: 14px; +} + +/* Studio Mode Gateway */ +.studio-mode-gateway { + position: relative; + display: grid; + width: 100%; + min-height: 100%; + grid-template-rows: auto 1fr auto; + gap: 34px; + padding: 112px clamp(30px, 6vw, 104px) 42px; + overflow: hidden; + background: + radial-gradient(circle at 78% 18%, rgba(88, 111, 94, 0.08), transparent 28%), + linear-gradient(150deg, #f8f7f3, #ecece7 74%); + animation: studio-mode-enter 520ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +@keyframes studio-mode-enter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.studio-mode-gateway::before { + position: absolute; + inset: 0; + content: ''; + opacity: 0.28; + background-image: radial-gradient(circle, rgba(27, 31, 28, 0.24) 1px, transparent 1.2px); + background-size: 24px 24px; + pointer-events: none; +} +.studio-mode-gateway > * { + position: relative; + z-index: 1; +} +.studio-mode-gateway__header { + display: grid; + max-width: 720px; + gap: 11px; +} +.studio-mode-gateway__header .overline { + color: #6e756f; + font: 700 8px/1 monospace; + letter-spacing: 0.18em; +} +.studio-mode-gateway__header h1 { + margin: 0; + font: + 500 clamp(34px, 4.2vw, 62px)/1.04 Georgia, + 'Songti SC', + serif; + letter-spacing: -0.035em; +} +.studio-mode-gateway__header p { + max-width: 610px; + margin: 0; + color: #666b67; + font-size: 12px; + line-height: 1.75; +} +.studio-mode-gateway__choices { + display: grid; + min-height: 360px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} +.studio-mode-card { + position: relative; + display: grid; + min-height: 100%; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + gap: 24px 30px; + padding: clamp(26px, 3.5vw, 52px); + overflow: hidden; + border: 1px solid rgba(20, 20, 20, 0.12); + border-radius: 24px; + color: #1a1b1a; + background: rgba(250, 250, 247, 0.78); + box-shadow: + 0 26px 64px rgba(28, 35, 30, 0.09), + inset 0 1px rgba(255, 255, 255, 0.72); + text-align: left; + cursor: pointer; + transition: + border-color 240ms ease, + box-shadow 240ms ease, + transform 240ms cubic-bezier(0.22, 1, 0.36, 1); + text-decoration: none; +} +.studio-mode-card:hover { + border-color: rgba(40, 69, 48, 0.36); + box-shadow: + 0 34px 80px rgba(25, 36, 28, 0.15), + inset 0 1px #fff; + transform: translateY(-6px); +} +.studio-mode-card__eyebrow { + font: 700 9px/1 monospace; + letter-spacing: 0.14em; + color: var(--wf-muted); + text-transform: uppercase; +} +.studio-mode-card__index { + font: + 500 48px/1 Georgia, + serif; + color: rgba(23, 24, 23, 0.08); +} +.studio-mode-card__copy { + display: grid; + gap: 8px; + align-content: start; +} +.studio-mode-card__copy small { + font: 700 8px/1 monospace; + letter-spacing: 0.14em; + color: var(--wf-muted); +} +.studio-mode-card__copy b { + font: + 500 20px/1.2 Georgia, + 'Songti SC', + serif; + color: var(--wf-ink); +} +.studio-mode-card__copy p { + margin: 0; + font-size: 12px; + color: #666b67; + line-height: 1.6; +} +.studio-mode-card__action { + align-self: end; + font: + 600 12px/1 system-ui, + sans-serif; + color: var(--wf-accent); +} +.studio-mode-gateway__note { + display: flex; + gap: 10px; + align-items: center; + padding: 12px 0; + border-top: 1px solid rgba(20, 20, 20, 0.08); + color: #666b67; + font-size: 11px; +} +.studio-mode-gateway__note i { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(38, 63, 45, 0.08); +} +.studio-mode-gateway__note b { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--wf-ink); +} +.studio-mode-gateway__note small { + font-size: 10px; +} + +/* Node Graph */ +.node-graph-workspace { + position: relative; + height: 100%; + overflow: hidden; +} +.node-canvas { + position: absolute; + inset: 0; + overflow: hidden; + cursor: grab; + touch-action: none; + user-select: none; + background-color: var(--wf-white); + background-image: radial-gradient(circle, rgba(46, 62, 50, 0.18) 1px, transparent 1.25px); + background-size: 21px 21px; +} +.node-canvas.is-panning { + cursor: grabbing; +} +.node-surface { + position: absolute; + top: 0; + left: 0; + width: 3000px; + height: 1000px; + transform-origin: 0 0; + will-change: transform; +} +.node-wires { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 3000px; + height: 1000px; + overflow: visible; + pointer-events: none; +} +.node-wire { + fill: none; + stroke-width: 3; + stroke: #69866f; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 2px 2px rgba(38, 55, 43, 0.18)); +} +.node-wire.is-suggested { + stroke: #b8b4aa; + stroke-dasharray: 7 6; + opacity: 0.5; +} + +/* Graph Node */ +.graph-node { + position: absolute; + z-index: 3; + width: 360px; + max-width: calc(100vw - 40px); + overflow: visible; + border: 1px solid rgba(27, 38, 30, 0.2); + border-radius: 12px; + background: rgba(251, 252, 248, 0.98); + box-shadow: 0 14px 36px rgba(30, 39, 32, 0.13); + cursor: default; +} +.graph-node--active { + border-color: rgba(198, 83, 53, 0.72); + box-shadow: + 0 0 0 3px rgba(198, 83, 53, 0.1), + 0 18px 44px rgba(30, 39, 32, 0.16); +} +.graph-node--active > header { + background: #56382f; +} +.graph-node--passed { + border-color: rgba(61, 107, 74, 0.38); +} +.graph-node.has-input { + border-color: rgba(66, 99, 75, 0.48); +} +.graph-node.is-dragging { + z-index: 8; + box-shadow: 0 23px 54px rgba(26, 37, 29, 0.2); +} +.graph-node > header { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + border-radius: 11px 11px 0 0; + color: #f2f5f1; + background: #26352b; + cursor: move; + user-select: none; +} +.graph-node > header span { + display: grid; + min-width: 0; + gap: 2px; +} +.graph-node > header small { + color: #aebcb1; + font-size: 6px; + font-weight: 800; + letter-spacing: 0.12em; +} +.graph-node > header h2 { + margin: 0; + font-size: 12px; + font-weight: 720; + line-height: 1.35; + overflow-wrap: anywhere; +} +.graph-node > header > i { + display: flex; + gap: 3px; +} +.graph-node > header > i b { + width: 3px; + height: 3px; + border-radius: 50%; + background: #89998c; +} +.graph-node__body { + display: grid; + min-width: 0; + gap: 10px; + padding: 15px; +} + +/* Graph Port */ +.graph-port { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + border: 3px solid #8fa292; + border-radius: 50%; + background: #f2f4f0; + transform: translateY(-50%); + cursor: pointer; + z-index: 5; +} +.graph-port--input { + left: -6px; +} +.graph-port--output { + right: -6px; +} +.graph-port[data-enabled='false'] { + border-color: #c8c2b7; + background: #ece9e1; + cursor: not-allowed; +} +.graph-port[data-enabled='true']:hover { + transform: translateY(-50%) scale(1.3); + border-color: var(--wf-accent); + background: var(--wf-accent-soft); +} + +/* Node Status */ +.node-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; + background: rgba(27, 38, 30, 0.04); +} +.node-status span { + min-width: 0; + font-size: 10px; + color: #5a5f5a; + line-height: 1.45; + overflow-wrap: anywhere; +} +.node-status b { + flex: 0 0 auto; + font-size: 10px; + font-weight: 700; +} +.node-status--active b { + color: var(--wf-accent); +} +.node-status--passed b { + color: #3d6b4a; +} +.node-status--failed b { + color: #8b332a; +} + +/* Node API Notice */ +.node-api-notice { + display: grid; + gap: 8px; + padding: 10px; + border: 1px dashed #c7a967; + border-radius: 8px; + background: #f4eddc; +} +.node-api-notice p { + margin: 0; + font-size: 10px; + color: #67552e; + line-height: 1.5; +} + +/* Node Desc */ +.node-desc { + margin: 0; + font-size: 11px; + color: #5a5f5a; + line-height: 1.5; + white-space: normal; + overflow-wrap: anywhere; +} + +/* 角色设定输入区必须在画布缩放和 Tailwind reset 下仍保持明确的编辑边界。 */ +.node-brief-form { + display: grid; + min-width: 0; + gap: 12px; + user-select: text; +} +.node-brief-form__field { + display: grid; + min-width: 0; + gap: 6px; +} +.node-brief-form__label { + color: #2f4134; + font-size: 11px; + font-weight: 750; + line-height: 1.4; +} +.node-brief-form__textarea { + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 116px; + max-height: 280px; + resize: vertical; + padding: 11px 12px; + overflow: auto; + border: 1px solid #98a59b; + border-radius: 8px; + outline: none; + color: #1f2d23; + background: #fff; + box-shadow: inset 0 1px 2px rgba(24, 39, 29, 0.08); + font-family: inherit; + font-size: 12px; + font-weight: 500; + line-height: 1.6; + caret-color: var(--wf-accent); + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} +.node-brief-form__textarea::placeholder { + color: #7b847d; + opacity: 1; +} +.node-brief-form__textarea:hover { + border-color: #75877a; +} +.node-brief-form__textarea:focus-visible { + border-color: var(--wf-accent); + box-shadow: + 0 0 0 3px rgba(38, 63, 45, 0.14), + inset 0 1px 2px rgba(24, 39, 29, 0.06); +} +.node-brief-form__hint { + color: #667169; + font-size: 9px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.node-template-upload { + display: grid; + gap: 5px; + padding: 9px 10px; + border: 1px dashed rgba(38, 63, 45, 0.38); + border-radius: 8px; + background: rgba(38, 63, 45, 0.04); +} +.node-template-upload > span { + color: #39493d; + font-size: 10px; + font-weight: 700; +} +.node-template-upload input[type='file'] { + width: 100%; + color: #515a53; + font-size: 10px; +} +.node-template-upload small { + color: #5a665d; + font-size: 9px; + line-height: 1.45; +} + +/* Node Action */ +.node-action { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 38px; + justify-content: center; + padding: 9px 12px; + border: 1px solid var(--wf-accent); + border-radius: 8px; + background: var(--wf-accent); + color: #f2f5f1; + font-size: 10px; + font-weight: 700; + cursor: pointer; + text-decoration: none; + text-align: left; +} +.node-action:hover { + background: #1d3025; +} +.node-action strong { + font-size: 11px; +} +.node-action small { + font-size: 9px; + opacity: 0.8; +} +.node-action--secondary { + background: transparent; + color: var(--wf-accent); +} +.node-action--secondary:hover { + background: var(--wf-accent-soft); +} +.node-action.is-disabled, +.node-action[aria-disabled='true'], +.node-action:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +/* Node Action List */ +.node-action-list { + display: grid; + gap: 6px; +} +.node-action-option { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px solid rgba(27, 38, 30, 0.15); + border-radius: 8px; + background: rgba(255, 255, 255, 0.6); + cursor: pointer; + text-align: left; +} +.node-action-option:hover { + border-color: rgba(38, 63, 45, 0.3); + background: rgba(255, 255, 255, 0.9); +} +.node-action-option strong { + font-size: 11px; + font-weight: 600; +} +.node-action-option small { + font-size: 9px; + color: var(--wf-muted); +} + +/* Node Candidate List */ +.node-candidate-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.node-candidate-intro { + display: grid; + gap: 3px; + padding: 10px 11px; + border-left: 3px solid var(--wf-choice); + background: #f6eee9; +} +.node-candidate-intro strong { + color: #7e3425; + font-size: 12px; +} +.node-candidate-intro span { + color: #6c5b55; + font-size: 10px; + line-height: 1.45; +} +.node-candidate { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + min-width: 0; + padding: 7px; + border: 2px solid transparent; + border-radius: 8px; + background: #f0f2ed; + cursor: pointer; + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} +.node-candidate:hover { + border-color: rgba(38, 63, 45, 0.34); + background: #fff; + transform: translateY(-1px); +} +.node-candidate:focus-visible { + outline: 3px solid rgba(36, 92, 120, 0.28); + outline-offset: 2px; +} +.node-candidate.is-selected { + border-color: var(--wf-choice); + background: #fff8f4; + box-shadow: 0 0 0 3px rgba(198, 83, 53, 0.13); +} +.node-candidate__image { + position: relative; + display: grid; + width: 100%; + aspect-ratio: 1; + place-items: center; + overflow: hidden; + border-radius: 5px; + background: #dfe4dd; +} +.node-candidate__image img { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; +} +.node-candidate__image i { + position: absolute; + top: 6px; + right: 6px; + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 2px solid #fff; + border-radius: 50%; + color: #fff; + background: var(--wf-choice); + box-shadow: 0 3px 10px rgba(70, 35, 27, 0.25); + font-size: 12px; + font-style: normal; + opacity: 0; + transform: scale(0.72); + transition: + opacity 160ms ease, + transform 160ms ease; +} +.node-candidate.is-selected .node-candidate__image i { + opacity: 1; + transform: scale(1); +} +.node-candidate small { + color: #515a53; + font-size: 10px; + font-weight: 700; +} +.node-candidate.is-selected small { + color: #8b3d2b; +} + +/* Node Export Options */ +.node-export-options { + display: grid; + gap: 8px; +} + +/* Completion choice — review completion never forces a route change. */ +.workflow-completion { + position: absolute; + z-index: 14; + top: 20px; + right: 20px; + display: grid; + width: min(340px, calc(100% - 40px)); + gap: 13px; + padding: 18px; + border: 1px solid rgba(35, 57, 42, 0.22); + border-radius: 8px; + background: rgba(251, 252, 248, 0.96); + box-shadow: 0 22px 54px rgba(26, 37, 29, 0.18); + backdrop-filter: blur(16px); + animation: workflow-completion-enter 260ms ease-out both; +} +.workflow-completion header { + display: flex; + gap: 11px; + align-items: center; +} +.workflow-completion header > span { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + place-items: center; + border-radius: 50%; + color: #fff; + background: #3d6b4a; + font-weight: 800; +} +.workflow-completion header div { + display: grid; + gap: 3px; +} +.workflow-completion header small { + color: #6f766f; + font-family: ui-monospace, monospace; + font-size: 8px; + font-weight: 700; +} +.workflow-completion h2 { + margin: 0; + color: var(--wf-ink); + font-size: 16px; +} +.workflow-completion > p { + margin: 0; + color: #5c655e; + font-size: 11px; + line-height: 1.65; +} +.workflow-completion__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.workflow-completion__actions button { + min-height: 40px; + border-radius: 7px; + font-size: 11px; + font-weight: 750; + cursor: pointer; +} +.workflow-completion__primary { + border: 1px solid var(--wf-preview); + color: #fff; + background: var(--wf-preview); +} +.workflow-completion__primary:hover { + background: #19465e; +} +.workflow-completion__secondary { + border: 1px solid #b9c1ba; + color: #3f4942; + background: transparent; +} +.workflow-completion__secondary:hover { + background: #edf0eb; +} +.workflow-completion .workflow-completion__export-note { + padding-top: 11px; + border-top: 1px solid #dde1dc; + color: #6b625a; + font-size: 10px; +} +.workflow-completion .workflow-completion__message { + padding: 8px 10px; + color: #355440; + background: #eaf1e9; + font-weight: 650; +} +.workflow-action-error { + position: absolute; + z-index: 15; + right: 20px; + bottom: 20px; + max-width: 360px; + margin: 0; + padding: 11px 13px; + border: 1px solid #d49b8f; + border-radius: 8px; + color: #7e2f25; + background: #fff1ed; + font-size: 11px; +} +@keyframes workflow-completion-enter { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Node Canvas Hint */ +.node-canvas-hint { + position: absolute; + bottom: 16px; + left: 50%; + z-index: 10; + display: flex; + min-height: 44px; + padding: 8px 16px; + transform: translateX(-50%); + border: 1px solid rgba(31, 43, 34, 0.1); + border-radius: 12px; + background: rgba(249, 250, 247, 0.88); + backdrop-filter: blur(18px); +} +.node-canvas-hint__copy { + display: flex; + flex-direction: column; + gap: 2px; +} +.node-canvas-hint__copy b { + font-size: 11px; + font-weight: 600; +} +.node-canvas-hint__copy > span { + font-size: 10px; + color: var(--wf-muted); +} + +/* Node Zoom */ +.node-zoom { + position: absolute; + bottom: 16px; + right: 16px; + z-index: 10; + display: flex; + gap: 4px; + align-items: center; + padding: 4px; + border: 1px solid rgba(31, 43, 34, 0.1); + border-radius: 10px; + background: rgba(249, 250, 247, 0.88); +} +.node-zoom button { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: none; + border-radius: 6px; + background: transparent; + font-size: 14px; + cursor: pointer; +} +.node-zoom button:hover { + background: rgba(255, 255, 255, 0.8); +} +.node-zoom output { + min-width: 36px; + text-align: center; + font-family: ui-monospace, monospace; + font-size: 10px; +} + +/* Node Generation Dots — 9×9 grid with ring-based animation */ +.node-generation__dots { + display: grid; + grid-template-columns: repeat(9, 6px); + gap: 2px; + justify-content: center; + padding: 8px; +} +.node-generation__dots i { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(38, 63, 45, 0.08); +} +.node-generation__dots i.dot-ring-0 { + background: var(--wf-accent); + animation: dot-pulse 1.2s ease infinite; +} +.node-generation__dots i.dot-ring-1 { + background: rgba(38, 63, 45, 0.25); + animation: dot-pulse 1.2s ease 0.1s infinite; +} +.node-generation__dots i.dot-ring-2 { + background: rgba(38, 63, 45, 0.15); + animation: dot-pulse 1.2s ease 0.2s infinite; +} +@keyframes dot-pulse { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 50% { + opacity: 1; + transform: scale(1.2); + } +} + +/* Node Frame Strip — per-frame arrival */ +.node-frame-strip { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 4px; +} +.node-frame-strip span { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.node-frame-strip span small { + font-size: 7px; + color: var(--wf-muted); +} +.node-frame-strip .is-arrived { + animation: frame-arrive 0.4s ease; +} +.node-frame-strip .is-pending i { + display: block; + width: 28px; + height: 28px; + border-radius: 4px; + background: rgba(27, 38, 30, 0.06); + animation: frame-pending 1.5s ease infinite; +} +@keyframes frame-arrive { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes frame-pending { + 0%, + 100% { + opacity: 0.2; + } + 50% { + opacity: 0.5; + } +} + +/* Node Connect Surface — click-to-confirm overlay */ +.graph-node__connect-surface { + display: none; + position: absolute; + inset: 0; + z-index: 10; + border: 2px dashed var(--wf-accent); + border-radius: 12px; + background: rgba(38, 63, 45, 0.06); + cursor: pointer; + place-items: center; +} +.graph-node.is-waiting-connection .graph-node__connect-surface { + display: grid; +} +.graph-node__connect-surface span { + font-size: 10px; + font-weight: 600; + color: var(--wf-accent); +} + +/* Node Running State */ +.graph-node.is-running { + box-shadow: + 0 0 0 2px rgba(87, 121, 96, 0.18), + 0 18px 48px rgba(30, 45, 34, 0.18); +} + +/* Connection Flash Animation */ +.graph-node.is-connection-committed { + animation: connection-flash 0.9s ease; +} +@keyframes connection-flash { + 0% { + box-shadow: 0 0 0 0 rgba(38, 63, 45, 0.3); + } + 50% { + box-shadow: 0 0 0 8px rgba(38, 63, 45, 0.15); + } + 100% { + box-shadow: 0 14px 36px rgba(30, 39, 32, 0.13); + } +} + +/* Wire Draw Animation */ +.node-wire.is-new { + animation: wire-draw 0.6s ease forwards; + stroke-dasharray: 1; + stroke-dashoffset: 1; +} +@keyframes wire-draw { + to { + stroke-dashoffset: 0; + } +} + +/* Port Highlight */ +.graph-port.is-connectable { + border-color: var(--wf-accent); + background: var(--wf-accent-soft); + box-shadow: 0 0 0 3px rgba(38, 63, 45, 0.15); + animation: port-pulse 1s ease infinite; +} +@keyframes port-pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px rgba(38, 63, 45, 0.15); + } + 50% { + box-shadow: 0 0 0 6px rgba(38, 63, 45, 0.08); + } +} + +@media (max-width: 700px) { + .studio-bar { + min-height: 58px; + gap: 8px; + padding: 0 10px; + } + .studio-bar__left, + .studio-bar__right { + gap: 6px; + } + .studio-bar__project { + display: none; + } + .studio-bar__nav { + gap: 2px; + } + .studio-bar__nav a { + min-height: 34px; + padding: 0 7px; + font-size: 10px; + } + .studio-bar__actions button { + min-height: 34px; + padding: 0 8px; + font-size: 10px; + } + .production-canvas-workspace { + height: calc(100svh - 58px); + min-height: 0; + } + .node-canvas-hint { + right: 132px; + bottom: 12px; + left: 12px; + min-height: 40px; + transform: none; + } + .node-canvas-hint__copy > span { + display: none; + } + .node-zoom { + right: 12px; + bottom: 12px; + } + .workflow-completion { + top: 12px; + right: 12px; + width: calc(100% - 24px); + } +}