diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d..3347478 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 是单次生成任务状态,不等于 WorkflowRun 或卡片的状态。 + * 一个 Run/Step 可以引用零个、一个或多个 GenerationTask。 * pending 表示已提交但尚未执行。 */ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359d..e825fa1 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,6 +1,12 @@ /** - * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * Entity 层的唯一公开入口。 + * + * Page 和 Feature 只从 `@/entities` 导入,不直接访问某个 Entity 的内部文件。 + * 这不是为了少写一段路径,而是为了稳定模块边界:内部文件可以重构, + * 但公开名称和依赖方向必须经过本文件明确审核。 + * + * 这里只暴露 Entity 级别的数据结构、后端端口契约以及必要的本地 Store 工厂。 + * 页面状态、路由、弹窗和按钮行为不属于 Entity,不应从此处导出。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ @@ -55,19 +61,42 @@ export type { /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' -/* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +/* + * 工作流 —— 记录“一次用户任务如何运行”。 + * 它不是角色/动作资产,也不是负责调后端的 WorkflowController。 + */ +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + createWorkflowRunRepository, + createWorkflowRunService, + isWorkflowRunSnapshot, + WORKFLOW_STEP_ORDERS, +} from './workflow-run' export type { + ActionFirstFrameCandidateBatch, + ActionReviewResult, + BuiltinWorkflowRunSource, + ConfigureWorkflowActionInput, + CreateWorkflowRunRepositoryOptions, + CreateWorkflowRunServiceOptions, CreateWorkflowRunInput, - ExportStatus, - GenerationStatus, - WorkflowDriver, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + PublishActionResult, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowRun, + WorkflowRunKind, + WorkflowRunRepository, + WorkflowRevision, + WorkflowRunService, + WorkflowRunSnapshot, + WorkflowRunStatus, WorkflowStep, + WorkflowStepPhase, WorkflowStepStatus, WorkflowStepType, - WorkflowRevision, - WorkflowRevisionStatus, - 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 0000000..b4ab2e5 --- /dev/null +++ b/frontend/src/entities/workflow-run/README.md @@ -0,0 +1,42 @@ +# WorkflowRun + +WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模式,也不表示单个生成任务。 + +## 数据关系 + +```text +WorkflowDefinition(未来由 Workflow Editor 管理) + └─ WorkflowRun(一次执行) + └─ WorkflowRevision(从某张卡片重做形成的执行分支) + └─ WorkflowStep(与编辑器卡片一一对应) + └─ GenerationTask 引用(可以有 0、1 或多个) +``` + +Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 +`ai/manual driver`。角色创建和首个动作在 Quick Start 中属于一个根任务;用户以后 +单独追加动作时才创建另一个 Run。 + +## Step 与卡片 + +当前内置流程只有 `character` 和 `action` 两种 Step。角色卡片内部依次经历“生成四张候选、 +选择一张”;动作卡片内部依次经历“配置、生成四张首帧、选择首帧、生成动画、审核、导出”。 +这些内部过程由 `phase` 表达,不拆成额外 Step,因此编辑器无需再写一层合并转换逻辑。 + +## Revision 解决什么 + +Quick Start 默认只有一个初始 Revision。Workflow Editor 从历史卡片重做时,在同一个 Run +中追加新 Revision:目标卡片之前已通过的结果可以复用,目标卡片及其后续内容被重置,旧 +Revision 保持只读。异步请求返回时还要核对发起它的 Revision,避免旧分支的晚到结果污染 +当前分支。 + +WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 WorkflowDefinition 的定义 +版本。用户从新的根节点发起任务时仍创建新 Run,而不是给旧 Run 追加 Revision。 + +## 职责 + +- `model` 不依赖页面、localStorage 或 SSE。 +- `store` 只负责异步持久化,不提供页面订阅。 +- `service` 只提供 `create/get`,返回绑定 Run ID 的实例;运行操作不再重复传 `runId`。 +- 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 +- Generation SSE 继续由 Generation Entity 负责。 +- 本地快照格式已升到 v5;旧的无 Revision 快照不会被误水合为新结构。 diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b..9b61ee7 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,157 +1,42 @@ -import type { Generation } from '../generation' - -/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' - -/** 创建 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' - -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { - /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ - id: string - type: WorkflowStepType - status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown - /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 - * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 - * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 - */ - taskId: Generation['id'] | null - /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ - referenceStepIds: string[] -} - -/** - * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 - * - * 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 -} - -/** - * 一次由前端推进的页面流程。 - * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 - * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 - */ -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 -} - -/** 两种入口共享的创建字段。 */ -interface CreateWorkflowRunInputBase { - projectId: string - driver: WorkflowDriver - /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ - prompt?: string -} - -/** - * 创建 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[] - } - ) +/** WorkflowRun Entity 的唯一公开入口。 */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './model' +export type { + BuiltinWorkflowRunSource, + ConfigureWorkflowActionInput, + CreateWorkflowRunInput, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowRunKind, + WorkflowRevision, + WorkflowRunSnapshot, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepPhase, + WorkflowStepStatus, + WorkflowStepType, +} from './model' +export { + createWorkflowRunRepository, + isWorkflowRunSnapshot, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' +export type { CreateWorkflowRunRepositoryOptions, WorkflowRunRepository } from './store' +export { createWorkflowRunService } from './service' +export type { + ActionFirstFrameCandidateBatch, + ActionReviewResult, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + CreateWorkflowRunServiceOptions, + PublishActionResult, + WorkflowRun, + WorkflowRunService, +} from './service' diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts new file mode 100644 index 0000000..66549fb --- /dev/null +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -0,0 +1,46 @@ +/** WorkflowRun 使用的稳定业务词汇。 */ + +/** + * 一个 Run 对应从一个根节点开始的一次执行。 + * `character_action` 会在同一个 Run 中先完成角色卡片,再完成动作卡片; + * `add_action` 则从已有角色开始,只包含动作卡片。 + */ +export const WORKFLOW_RUN_KINDS = ['character_action', 'add_action'] as const + +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const + +/** Step 与 Workflow Editor 中用户看到的卡片一一对应。 */ +export const WORKFLOW_STEP_TYPES = ['character', 'action'] as const + +export const WORKFLOW_STEP_STATUSES = ['locked', 'active', 'passed', 'failed'] as const + +/** + * phase 描述卡片内部正在做什么,不再把“生成”和“选择”伪装成两张卡片。 + * 不同类型的 Step 只能使用各自对应的 phase,校验规则在 Repository 中集中维护。 + */ +export const WORKFLOW_STEP_PHASES = [ + 'generating_character_candidates', + 'selecting_character', + 'configuring_action', + 'generating_action_candidates', + 'selecting_action_frame', + 'generating_animation', + 'reviewing_animation', + 'exporting_action', + 'completed', +] as const + +export const WORKFLOW_GENERATION_ROLES = [ + 'character_candidates', + 'action_frame_candidate', + 'animation', +] as const + +export const CHARACTER_CANDIDATE_COUNT = 4 +export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 + +/** 两个内置流程的卡片顺序;将来编辑器流程由 WorkflowDefinition 提供节点顺序。 */ +export const WORKFLOW_STEP_ORDERS = { + character_action: ['character', 'action'], + add_action: ['action'], +} as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts new file mode 100644 index 0000000..c507953 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -0,0 +1,24 @@ +/** WorkflowRun 的可序列化模型和内置流程模板。 */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' +export type { + BuiltinWorkflowRunSource, + ConfigureWorkflowActionInput, + CreateWorkflowRunInput, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowRunKind, + WorkflowRevision, + WorkflowRunSnapshot, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepPhase, + WorkflowStepStatus, + WorkflowStepType, +} from './types' diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts new file mode 100644 index 0000000..3c3e851 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -0,0 +1,138 @@ +/** WorkflowRun 的可序列化执行快照。 */ + +import type { ActionType } from '../../character' +import type { Generation } from '../../generation' +import type { MediaReference } from '../../media' +import { + WORKFLOW_GENERATION_ROLES, + WORKFLOW_RUN_KINDS, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_PHASES, + WORKFLOW_STEP_STATUSES, + WORKFLOW_STEP_TYPES, +} from './constants' + +export type WorkflowRunKind = (typeof WORKFLOW_RUN_KINDS)[number] +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] +export type WorkflowStepType = (typeof WORKFLOW_STEP_TYPES)[number] +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] +export type WorkflowStepPhase = (typeof WORKFLOW_STEP_PHASES)[number] +export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] + +/** + * Step 对后端 GenerationTask 的引用。 + * 一个角色卡片对应一次四图生成;一个动作卡片可以对应四次首帧生成和一次动画生成。 + */ +export interface WorkflowGenerationRef { + taskId: Generation['id'] + role: WorkflowGenerationRole +} + +/** + * 一个 Step 就是编辑器中的一张卡片;phase 是卡片内部状态。 + * 这样展示层不需要把多个技术步骤重新拼装成一张卡片。 + */ +export interface WorkflowStep { + id: string + /** 内置流程使用稳定节点名;未来编辑器运行时保存 Definition 中的 nodeId。 */ + nodeId: string + type: WorkflowStepType + status: WorkflowStepStatus + phase: WorkflowStepPhase + generations: WorkflowGenerationRef[] + error: string | null +} + +/** + * 同一个根任务中的一次执行分支。 + * + * 初始 Revision 没有父级;Workflow Editor 从某张卡片重做时,追加一个指向当前 + * Revision 的新成员。旧 Revision 不再修改,新 Revision 复用目标卡片之前已经通过的 + * 结果,并重置目标卡片及其后续卡片。 + */ +export interface WorkflowRevision { + id: string + parentRevisionId: string | null + /** 初始 Revision 为 null;后续 Revision 指向父 Revision 中触发重做的 Step。 */ + restartedFromStepId: WorkflowStep['id'] | null + steps: WorkflowStep[] + + /** 节点输入和产出属于执行分支,不能放在 Run 顶层覆盖旧 Revision。 */ + characterInput: WorkflowCharacterInput | null + characterId: string | null + outfitId: string | null + characterSelectedAt: string | null + actionInput: WorkflowActionInput | null + + createdAt: string +} + +/** 当前 PR 支持的内置流程来源。Workflow Editor 后续会扩展 definition 来源。 */ +export interface BuiltinWorkflowRunSource { + type: 'builtin' + key: WorkflowRunKind + rootNodeId: string +} + +export interface WorkflowCharacterInput { + prompt: string + referenceMedia: readonly MediaReference[] +} + +export interface WorkflowActionInput { + id: string + name: string + type: ActionType + prompt: string | null + fps: number +} + +/** + * 一次根任务的执行数据。 + * + * 这里故意没有 driver:自动或手动推进属于界面行为。Revision 只表达 Workflow Editor + * 在同一个根任务中“从某张卡片重做”的执行分支,不表达 GenerationTask,也不取代 + * WorkflowDefinition 的定义版本。 + */ +export interface WorkflowRunSnapshot { + id: string + projectId: string + source: BuiltinWorkflowRunSource + status: WorkflowRunStatus + currentRevisionId: WorkflowRevision['id'] + revisions: WorkflowRevision[] + + createdAt: string + updatedAt: string +} + +interface CreateWorkflowRunInputBase { + projectId: string +} + +/** 创建一个根任务,而不是创建一个 GenerationTask。 */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + kind: 'character_action' + characterPrompt: string + referenceMedia?: readonly MediaReference[] + } + | { + kind: 'add_action' + characterId: string + outfitId: string + actionName: string + actionType: ActionType + actionPrompt?: string | null + fps: number + } + ) + +/** 角色确认后,在同一个 character_action Run 中配置后续动作卡片。 */ +export interface ConfigureWorkflowActionInput { + actionName: string + actionType: ActionType + actionPrompt?: string | null + fps: number +} diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts new file mode 100644 index 0000000..a0ea6a9 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -0,0 +1,18 @@ +/** + * WorkflowRun 可执行用例的子目录入口。 + * + * model 只定义数据,store 只管快照,service 负责组合真实 Character/Generation + * 端口完成角色和动作任务。页面应调用这些用例,不自行改写 Run。 + */ + +export { createWorkflowRunService } from './workflow-run-service' +export type { + ActionFirstFrameCandidateBatch, + ActionReviewResult, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + CreateWorkflowRunServiceOptions, + PublishActionResult, + WorkflowRun, + WorkflowRunService, +} from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts new file mode 100644 index 0000000..93016d6 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Character, CharacterApis } from '../../character' +import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' +import type { WorkflowRunSnapshot } from '../model' +import { createWorkflowRunRepository } from '../store' +import { + createWorkflowRunService, + type ActionFirstFrameCandidateBatch, + type CharacterCandidateBatch, + type CharacterCandidateConfirmationApis, +} from './workflow-run-service' + +function createCharacter(): Character { + return { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: '默认造型', + candidateCharacterTemplates: [], + characterTemplateUrl: 'candidate-2.png', + baseFrames: [], + actions: [], + }, + ], + } +} + +function createGenerationApis() { + const tasks = new Map() + let nextId = 0 + const create = vi.fn(async (input: GenerationInput): Promise => { + const id = `generation-${++nextId}` + const result = + input.type === 'character_template' + ? { + type: 'character_template' as const, + images: [1, 2, 3, 4].map((index) => ({ url: `candidate-${index}.png` })), + } + : input.type === 'first_frame' + ? { type: 'first_frame' as const, image: { url: `first-frame-${id}.png` } } + : { + type: 'complete_animation' as const, + frames: [{ url: 'frame-1.png' }, { url: 'frame-2.png' }], + } + const task: Generation = { + id, + projectId: input.projectId, + type: input.type, + status: 'completed', + result, + error: null, + } + tasks.set(id, task) + return task + }) + const apis: GenerationApis = { + create, + async get(_projectId, id) { + const task = tasks.get(id) + if (!task) throw new Error('任务不存在') + return structuredClone(task) + }, + subscribe() { + return () => undefined + }, + } + return { apis, create, tasks } +} + +function createFixture() { + let id = 0 + let timestamp = 0 + const repository = createWorkflowRunRepository({ storage: null }) + const generation = createGenerationApis() + let character = createCharacter() + const characterApis: CharacterApis = { + get: vi.fn(async () => structuredClone(character)), + listByProject: vi.fn(async () => [structuredClone(character)]), + create: vi.fn(async () => structuredClone(character)), + update: vi.fn(async (next) => { + character = structuredClone(next) + return structuredClone(character) + }), + } + const confirmSelection = vi.fn(async () => ({ + character: structuredClone(character), + outfitId: 'outfit-1', + })) + const candidateConfirmationApis: CharacterCandidateConfirmationApis = { confirmSelection } + const service = createWorkflowRunService({ + repository, + generationApis: generation.apis, + characterApis, + candidateConfirmationApis, + createId: () => `workflow-id-${++id}`, + now: () => `2026-08-05T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + return { service, repository, generation, characterApis, confirmSelection } +} + +function currentSteps(snapshot: WorkflowRunSnapshot) { + return currentRevisionSnapshot(snapshot).steps +} + +function currentRevisionSnapshot(snapshot: WorkflowRunSnapshot) { + return snapshot.revisions.find((revision) => revision.id === snapshot.currentRevisionId)! +} + +describe('WorkflowRun instance', () => { + it('uses one run and two card-aligned steps for character plus first action', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + kind: 'character_action', + projectId: 'project-1', + characterPrompt: '一位像素风守夜人', + }) + const runId = run.id + expect(currentSteps(run.snapshot()).map((step) => step.type)).toEqual(['character', 'action']) + + const characters = (await run.start()) as CharacterCandidateBatch + expect(characters.candidates).toHaveLength(4) + expect(currentSteps(characters.snapshot)[0]).toMatchObject({ + type: 'character', + status: 'active', + phase: 'selecting_character', + }) + expect(JSON.stringify(await fixture.repository.get(runId))).not.toContain('candidate-1.png') + + await run.confirmCharacter('candidate-2.png') + expect(run.snapshot()).toMatchObject({ id: runId, status: 'active' }) + expect(currentRevisionSnapshot(run.snapshot())).toMatchObject({ + characterId: 'character-1', + outfitId: 'outfit-1', + }) + expect(currentSteps(run.snapshot())[1]).toMatchObject({ + type: 'action', + status: 'active', + phase: 'configuring_action', + }) + + run.configureAction({ + actionName: '向前行走', + actionType: 'walk', + actionPrompt: '轻快地向前行走', + fps: 12, + }) + const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch + expect(firstFrames.candidates).toHaveLength(4) + expect(firstFrames.snapshot.id).toBe(runId) + expect(currentSteps(firstFrames.snapshot)[1]).toMatchObject({ + phase: 'selecting_action_frame', + }) + + await run.confirmActionFirstFrame(firstFrames.candidates[1]!) + expect(currentSteps(run.snapshot())[1]).toMatchObject({ phase: 'reviewing_animation' }) + const published = await run.approveAction() + + expect(published.snapshot).toMatchObject({ id: runId, status: 'completed' }) + expect(currentSteps(published.snapshot)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'character', status: 'passed', phase: 'completed' }), + expect.objectContaining({ type: 'action', status: 'passed', phase: 'completed' }), + ]), + ) + expect(published.character.outfits[0]?.actions[0]).toMatchObject({ + name: '向前行走', + type: 'walk', + fps: 12, + }) + expect(fixture.generation.create).toHaveBeenCalledTimes(6) + expect(fixture.confirmSelection).toHaveBeenCalledTimes(1) + }) + + it('binds operations to the run instance and service only creates or restores instances', async () => { + const { service } = createFixture() + expect(Object.keys(service).sort()).toEqual(['create', 'get']) + const created = await service.create({ + kind: 'add_action', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '待机', + actionType: 'idle', + fps: 8, + }) + const restored = await service.get(created.id) + expect(restored?.id).toBe(created.id) + expect(restored?.snapshot()).toEqual(created.snapshot()) + }) + + it('keeps ordinary state transitions local until save is explicitly requested', async () => { + const { service, repository } = createFixture() + const run = await service.create({ + kind: 'add_action', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '待机', + actionType: 'idle', + fps: 8, + }) + + run.interrupt() + expect(run.snapshot().status).toBe('interrupted') + expect((await repository.get(run.id))?.status).toBe('active') + await run.save() + expect((await repository.get(run.id))?.status).toBe('interrupted') + run.continue() + expect(run.snapshot().status).toBe('active') + }) + + it('adds a read-only revision when Workflow Editor restarts from a card', async () => { + const { service } = createFixture() + const run = await service.create({ + kind: 'character_action', + projectId: 'project-1', + characterPrompt: '角色', + }) + const characterCandidates = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characterCandidates.candidates[0]!) + run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) + await run.start() + + const before = run.snapshot() + const parent = before.revisions[0]! + const actionStep = currentSteps(before)[1]! + const restarted = run.restartFromStep(actionStep.id) + const next = restarted.revisions[1]! + + expect(restarted.id).toBe(before.id) + expect(restarted.revisions).toHaveLength(2) + expect(next).toMatchObject({ + parentRevisionId: parent.id, + restartedFromStepId: actionStep.id, + }) + expect(next.steps[0]).toMatchObject({ type: 'character', status: 'passed', phase: 'completed' }) + expect(next.steps[0]?.generations).toEqual(parent.steps[0]?.generations) + expect(next.steps[0]?.id).not.toBe(parent.steps[0]?.id) + expect(next.characterId).toBe(parent.characterId) + expect(next.outfitId).toBe(parent.outfitId) + expect(next.actionInput).toEqual(parent.actionInput) + expect(next.steps[1]).toMatchObject({ + type: 'action', + status: 'active', + phase: 'configuring_action', + generations: [], + }) + expect(restarted.revisions[0]).toEqual(parent) + }) + + it('does not let an old revision asynchronous result mutate the new revision', async () => { + const fixture = createFixture() + const running: Generation<'character_template'> = { + id: 'generation-running', + projectId: 'project-1', + type: 'character_template', + status: 'running', + result: null, + error: null, + } + let emit: (event: GenerationEvent) => void = () => { + throw new Error('生成订阅尚未建立') + } + fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] + fixture.generation.apis.get = vi.fn(async () => running) + fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { + emit = onEvent + return () => undefined + }) + + const run = await fixture.service.create({ + kind: 'character_action', + projectId: 'project-1', + characterPrompt: '异步角色', + }) + const pending = run.start() + await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) + const oldStep = currentSteps(run.snapshot())[0]! + run.restartFromStep(oldStep.id) + emit({ + taskId: running.id, + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [1, 2, 3, 4].map((index) => ({ url: `late-${index}.png` })), + }, + error: null, + }) + + await expect(pending).rejects.toThrow('已切换到新的 Revision') + const snapshot = run.snapshot() + expect(snapshot.revisions).toHaveLength(2) + expect(currentSteps(snapshot)[0]).toMatchObject({ + status: 'active', + phase: 'generating_character_candidates', + generations: [], + }) + }) + + it('rejects character and action candidates that do not belong to this run', async () => { + const { service, generation, confirmSelection } = createFixture() + const run = await service.create({ + kind: 'character_action', + projectId: 'project-1', + characterPrompt: '角色', + }) + await run.start() + await expect(run.confirmCharacter('foreign.png')).rejects.toThrow('不属于当前角色生成任务') + expect(confirmSelection).not.toHaveBeenCalled() + + const actionRun = await service.create({ + kind: 'add_action', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + await actionRun.start() + await expect(actionRun.confirmActionFirstFrame('foreign.png')).rejects.toThrow( + '不属于当前动作首帧任务', + ) + expect(generation.create).toHaveBeenCalledTimes(5) + }) + + it('resumes an action from persisted GenerationTask references', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + kind: 'add_action', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch + await run.confirmActionFirstFrame(firstFrames.candidates[0]!) + + const restored = (await fixture.service.get(run.id))! + const resumed = await restored.resumeAction() + expect(currentSteps(resumed)[0]).toMatchObject({ phase: 'reviewing_animation' }) + expect(fixture.generation.create).toHaveBeenCalledTimes(5) + }) +}) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts new file mode 100644 index 0000000..bb746c7 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -0,0 +1,787 @@ +/** WorkflowRun 的运行实例与用例入口。 */ + +import type { Action, Character, CharacterApis, Frame } from '../../character' +import type { + CharacterTemplateGenerationResult, + CompleteAnimationGenerationResult, + Generation, + GenerationApis, + GenerationEvent, +} from '../../generation' +import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' +import type { + ConfigureWorkflowActionInput, + CreateWorkflowRunInput, + WorkflowGenerationRole, + WorkflowRevision, + WorkflowRunSnapshot, + WorkflowStep, + WorkflowStepType, +} from '../model' +import type { WorkflowRunRepository } from '../store' + +/** + * 确认角色候选的后端原子操作。 + * 后端保存选中图并清理其余三个临时候选,前端只接收正式角色和造型 ID。 + */ +export interface CharacterCandidateConfirmationApis { + confirmSelection(input: { + projectId: string + generationId: string + selectedImageUrl: string + description: string + }): Promise<{ character: Character; outfitId: string }> +} + +export interface CharacterCandidateBatch { + snapshot: WorkflowRunSnapshot + generationId: string + /** 候选 URL 只用于当前选择界面,不写入 WorkflowRun。 */ + candidates: readonly string[] +} + +export interface ActionFirstFrameCandidateBatch { + snapshot: WorkflowRunSnapshot + candidateTaskIds: readonly string[] + /** 候选 URL 只用于当前选择界面,不写入 WorkflowRun。 */ + candidates: readonly string[] +} + +export interface ActionReviewResult { + snapshot: WorkflowRunSnapshot + generationId: string + frames: readonly { imageUrl: string }[] +} + +export interface PublishActionResult { + snapshot: WorkflowRunSnapshot + character: Character + characterId: string + outfitId: string + actionId: string +} + +/** + * 绑定具体 Run 的运行对象。页面持有这个对象即可,不再同时传递 Service 和 runId。 + * `snapshot()` 返回可渲染数据;`save()` 是显式持久化边界。 + */ +export interface WorkflowRun { + readonly id: string + snapshot(): WorkflowRunSnapshot + save(): Promise + interrupt(): WorkflowRunSnapshot + continue(): WorkflowRunSnapshot + /** Workflow Editor 从指定卡片重做,并把旧 Revision 保留为只读历史。 */ + restartFromStep(stepId: WorkflowStep['id']): WorkflowRunSnapshot + start(): Promise + resumeCharacterCandidates(): Promise + confirmCharacter(selectedImageUrl: string): Promise + configureAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot + resumeActionFirstFrameCandidates(): Promise + confirmActionFirstFrame(selectedImageUrl: string): Promise + resumeAction(): Promise + getActionReview(): Promise + approveAction(): Promise +} + +/** Service 只负责创建或恢复运行实例。 */ +export interface WorkflowRunService { + create(input: CreateWorkflowRunInput): Promise + get(runId: WorkflowRunSnapshot['id']): Promise +} + +export interface CreateWorkflowRunServiceOptions { + repository: WorkflowRunRepository + generationApis: GenerationApis + characterApis: CharacterApis + candidateConfirmationApis: CharacterCandidateConfirmationApis + createId?: () => string + now?: () => string +} + +export function createWorkflowRunService( + options: CreateWorkflowRunServiceOptions, +): WorkflowRunService { + const createId = options.createId ?? createRandomId + const now = options.now ?? (() => new Date().toISOString()) + + function bind(initial: WorkflowRunSnapshot): WorkflowRun { + let state = structuredClone(initial) + + const current = (): WorkflowRunSnapshot => structuredClone(state) + const replace = (next: WorkflowRunSnapshot): WorkflowRunSnapshot => { + state = structuredClone(next) + return current() + } + const persist = async (): Promise => { + state = await options.repository.save(state) + return current() + } + const mutate = (edit: (draft: WorkflowRunSnapshot) => void): WorkflowRunSnapshot => { + const draft = current() + edit(draft) + draft.updatedAt = now() + return replace(draft) + } + const checkpoint = async (edit: (draft: WorkflowRunSnapshot) => void) => { + mutate(edit) + return persist() + } + + async function fail(message: string, revisionId: string): Promise { + if (state.status !== 'active' || state.currentRevisionId !== revisionId) return + mutate((draft) => { + assertCurrentRevision(draft, revisionId) + const step = requireActiveStep(draft) + step.status = 'failed' + step.error = message + draft.status = 'failed' + }) + await persist() + } + + async function generateCharacterCandidates(): Promise { + assertActive(state) + const revisionId = state.currentRevisionId + const step = requireStep(state, 'character') + if (step.phase === 'selecting_character') return loadCharacterCandidates() + if (step.phase !== 'generating_character_candidates') { + throw new Error('角色卡片当前不能生成候选图') + } + const input = currentRevision(state).characterInput + if (!input) throw new Error('WorkflowRun 缺少角色生成输入') + + try { + let generationId = generationIdFor(step, 'character_candidates') + if (!generationId) { + const generation = await options.generationApis.create({ + type: 'character_template', + projectId: state.projectId, + prompt: input.prompt, + referenceMedia: input.referenceMedia, + }) + generationId = generation.id + assertCurrentRevision(state, revisionId) + await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + requireStep(draft, 'character').generations.push({ + taskId: generation.id, + role: 'character_candidates', + }) + }) + } + const terminal = await waitForTerminal( + options.generationApis, + await options.generationApis.get(state.projectId, generationId), + ) + const result = requireCharacterCandidates(terminal) + assertActive(state) + assertCurrentRevision(state, revisionId) + await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + requireStep(draft, 'character').phase = 'selecting_character' + }) + return toCharacterBatch(state, generationId, result) + } catch (cause) { + await fail(errorMessage(cause, '角色候选生成失败'), revisionId) + throw asError(cause) + } + } + + async function loadCharacterCandidates(): Promise { + const step = requireStep(state, 'character') + const generationId = generationIdFor(step, 'character_candidates') + if (!generationId) throw new Error('角色候选任务 ID 不存在') + const result = requireCharacterCandidates( + await options.generationApis.get(state.projectId, generationId), + ) + return toCharacterBatch(state, generationId, result) + } + + async function collectActionCandidates(): Promise { + assertActive(state) + const revisionId = state.currentRevisionId + const step = requireStep(state, 'action') + if (step.phase === 'selecting_action_frame') return loadActionCandidates() + if (step.phase !== 'generating_action_candidates') { + throw new Error('动作卡片当前不能生成首帧候选') + } + const action = requireActionInput(state) + const { characterId, outfitId } = requireCharacterBinding(state) + const character = await options.characterApis.get(characterId) + assertCurrentRevision(state, revisionId) + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit?.characterTemplateUrl) throw new Error('动作生成需要已确认的角色母版') + + try { + while ( + generationIdsFor(requireStep(state, 'action'), 'action_frame_candidate').length < + ACTION_FIRST_FRAME_CANDIDATE_COUNT + ) { + const generation = await options.generationApis.create({ + type: 'first_frame', + projectId: state.projectId, + characterId, + outfitId, + actionType: action.type, + prompt: action.prompt, + referenceMedia: [], + }) + assertCurrentRevision(state, revisionId) + await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + requireStep(draft, 'action').generations.push({ + taskId: generation.id, + role: 'action_frame_candidate', + }) + }) + } + + const batch = await loadActionCandidates() + assertActive(state) + assertCurrentRevision(state, revisionId) + await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + requireStep(draft, 'action').phase = 'selecting_action_frame' + }) + return { ...batch, snapshot: current() } + } catch (cause) { + await fail(errorMessage(cause, '动作首帧候选生成失败'), revisionId) + throw asError(cause) + } + } + + async function loadActionCandidates(): Promise { + const step = requireStep(state, 'action') + const taskIds = generationIdsFor(step, 'action_frame_candidate') + if (taskIds.length !== ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + throw new Error(`动作首帧必须包含 ${ACTION_FIRST_FRAME_CANDIDATE_COUNT} 个候选任务`) + } + const candidates = await Promise.all( + taskIds.map(async (taskId) => { + const terminal = await waitForTerminal( + options.generationApis, + await options.generationApis.get(state.projectId, taskId), + ) + return requireFirstFrame(terminal) + }), + ) + return { snapshot: current(), candidateTaskIds: taskIds, candidates } + } + + const run: WorkflowRun = { + id: state.id, + snapshot: current, + save: persist, + interrupt() { + if (state.status !== 'active') throw new Error('只有进行中的 WorkflowRun 可以中断') + return mutate((draft) => { + draft.status = 'interrupted' + }) + }, + continue() { + if (state.status !== 'interrupted') throw new Error('只有已中断的 WorkflowRun 可以继续') + return mutate((draft) => { + draft.status = 'active' + }) + }, + restartFromStep(stepId) { + const parent = currentRevision(state) + const restartIndex = parent.steps.findIndex((step) => step.id === stepId) + if (restartIndex < 0) throw new Error('重做目标不属于当前 Revision') + if (parent.steps.slice(0, restartIndex).some((step) => step.status !== 'passed')) { + throw new Error('目标卡片之前仍有未通过步骤,不能从这里重做') + } + + const createdAt = now() + const revision: WorkflowRevision = { + id: createId(), + parentRevisionId: parent.id, + restartedFromStepId: stepId, + steps: parent.steps.map((step, index) => + createRestartedStep(step, index, restartIndex, createId), + ), + characterInput: structuredClone(parent.characterInput), + characterId: parent.characterId, + outfitId: parent.outfitId, + characterSelectedAt: parent.characterSelectedAt, + actionInput: structuredClone(parent.actionInput), + createdAt, + } + if (parent.steps[restartIndex]!.type === 'character') { + revision.characterId = null + revision.outfitId = null + revision.characterSelectedAt = null + revision.actionInput = null + } + return mutate((draft) => { + draft.revisions.push(revision) + draft.currentRevisionId = revision.id + draft.status = 'active' + }) + }, + async start() { + const step = requireActiveStep(state) + return step.type === 'character' ? generateCharacterCandidates() : collectActionCandidates() + }, + resumeCharacterCandidates: generateCharacterCandidates, + async confirmCharacter(selectedImageUrl) { + assertActive(state) + const revisionId = state.currentRevisionId + const step = requireStep(state, 'character') + if (step.phase !== 'selecting_character') throw new Error('角色尚未进入候选选择阶段') + const batch = await loadCharacterCandidates() + if (!batch.candidates.includes(selectedImageUrl)) { + throw new Error('选中图片不属于当前角色生成任务') + } + assertCurrentRevision(state, revisionId) + const characterInput = currentRevision(state).characterInput + if (!characterInput) throw new Error('WorkflowRun 缺少角色生成输入') + const confirmed = await options.candidateConfirmationApis.confirmSelection({ + projectId: state.projectId, + generationId: batch.generationId, + selectedImageUrl, + description: characterInput.prompt, + }) + assertCurrentRevision(state, revisionId) + return checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + const characterStep = requireStep(draft, 'character') + characterStep.status = 'passed' + characterStep.phase = 'completed' + const revision = currentRevision(draft) + revision.characterId = confirmed.character.id + revision.outfitId = confirmed.outfitId + revision.characterSelectedAt = now() + const actionStep = requireStep(draft, 'action') + actionStep.status = 'active' + actionStep.phase = 'configuring_action' + }) + }, + configureAction(input) { + assertActive(state) + const step = requireStep(state, 'action') + if (step.phase !== 'configuring_action') throw new Error('动作卡片当前不能配置') + validateActionInput(input) + return mutate((draft) => { + currentRevision(draft).actionInput = { + id: createId(), + name: input.actionName.trim(), + type: input.actionType, + prompt: input.actionPrompt?.trim() || null, + fps: input.fps, + } + requireStep(draft, 'action').phase = 'generating_action_candidates' + }) + }, + resumeActionFirstFrameCandidates: collectActionCandidates, + async confirmActionFirstFrame(selectedImageUrl) { + assertActive(state) + const revisionId = state.currentRevisionId + const step = requireStep(state, 'action') + if (step.phase !== 'selecting_action_frame') { + throw new Error('动作尚未进入首帧选择阶段') + } + const batch = await loadActionCandidates() + if (!batch.candidates.includes(selectedImageUrl)) { + throw new Error('选中图片不属于当前动作首帧任务') + } + assertCurrentRevision(state, revisionId) + const action = requireActionInput(state) + const { characterId, outfitId } = requireCharacterBinding(state) + try { + const generation = await options.generationApis.create({ + type: 'complete_animation', + projectId: state.projectId, + characterId, + outfitId, + actionType: action.type, + firstFrameUrl: selectedImageUrl, + prompt: action.prompt, + referenceMedia: [], + }) + assertCurrentRevision(state, revisionId) + await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + const actionStep = requireStep(draft, 'action') + actionStep.generations.push({ taskId: generation.id, role: 'animation' }) + actionStep.phase = 'generating_animation' + }) + return run.resumeAction() + } catch (cause) { + await fail(errorMessage(cause, '完整动画生成失败'), revisionId) + throw asError(cause) + } + }, + async resumeAction() { + assertActive(state) + const revisionId = state.currentRevisionId + const step = requireStep(state, 'action') + if (step.phase === 'reviewing_animation') return current() + if (step.phase !== 'generating_animation') throw new Error('动作当前不在动画生成阶段') + const taskId = generationIdFor(step, 'animation') + if (!taskId) throw new Error('完整动画任务 ID 不存在') + try { + requireAnimation( + await waitForTerminal( + options.generationApis, + await options.generationApis.get(state.projectId, taskId), + ), + ) + assertActive(state) + assertCurrentRevision(state, revisionId) + return checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + requireStep(draft, 'action').phase = 'reviewing_animation' + }) + } catch (cause) { + await fail(errorMessage(cause, '完整动画恢复失败'), revisionId) + throw asError(cause) + } + }, + async getActionReview() { + const step = requireStep(state, 'action') + if (state.status !== 'active' || step.phase !== 'reviewing_animation') { + throw new Error('动作尚未进入可审核状态') + } + const generationId = generationIdFor(step, 'animation') + if (!generationId) throw new Error('完整动画任务 ID 不存在') + const animation = requireAnimation( + await options.generationApis.get(state.projectId, generationId), + ) + return { + snapshot: current(), + generationId, + frames: animation.frames.map((frame) => ({ imageUrl: frame.url })), + } + }, + async approveAction() { + const revisionId = state.currentRevisionId + const review = await run.getActionReview() + assertCurrentRevision(state, revisionId) + const actionInput = requireActionInput(state) + const { characterId, outfitId } = requireCharacterBinding(state) + const character = await options.characterApis.get(characterId) + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit) throw new Error('动作所属造型不存在') + const action: Action = { + id: actionInput.id, + outfitId, + name: actionInput.name, + kind: 'custom', + type: actionInput.type, + fps: actionInput.fps, + keyFrameIndex: null, + frames: review.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + durationMs: null, + rootMotion: null, + })), + } + const savedCharacter = await options.characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfitId + ? { + ...item, + actions: [ + ...item.actions.filter((existing) => existing.id !== actionInput.id), + action, + ], + } + : item, + ), + }) + assertCurrentRevision(state, revisionId) + await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + const actionStep = requireStep(draft, 'action') + actionStep.phase = 'completed' + actionStep.status = 'passed' + draft.status = 'completed' + }) + return { + snapshot: current(), + character: savedCharacter, + characterId, + outfitId, + actionId: actionInput.id, + } + }, + } + return run + } + + return { + async create(input) { + const state = createInitialSnapshot(input, createId, now) + return bind(await options.repository.create(state)) + }, + async get(runId) { + const state = await options.repository.get(runId) + return state ? bind(state) : null + }, + } +} + +function createInitialSnapshot( + input: CreateWorkflowRunInput, + createId: () => string, + now: () => string, +): WorkflowRunSnapshot { + if (!input.projectId.trim()) throw new TypeError('projectId 不能为空') + if (input.kind === 'character_action' && !input.characterPrompt.trim()) { + throw new TypeError('角色描述不能为空') + } + if (input.kind === 'add_action') validateActionInput(input) + + const createdAt = now() + const characterStep = (): WorkflowStep => ({ + id: createId(), + nodeId: 'builtin-character', + type: 'character', + status: 'active', + phase: 'generating_character_candidates', + generations: [], + error: null, + }) + const actionStep = (active: boolean): WorkflowStep => ({ + id: createId(), + nodeId: 'builtin-action', + type: 'action', + status: active ? 'active' : 'locked', + phase: active ? 'generating_action_candidates' : 'configuring_action', + generations: [], + error: null, + }) + const steps = + input.kind === 'character_action' ? [characterStep(), actionStep(false)] : [actionStep(true)] + const initialRevisionId = createId() + + return { + id: createId(), + projectId: input.projectId.trim(), + source: { type: 'builtin', key: input.kind, rootNodeId: steps[0]!.nodeId }, + status: 'active', + currentRevisionId: initialRevisionId, + revisions: [ + { + id: initialRevisionId, + parentRevisionId: null, + restartedFromStepId: null, + steps, + characterInput: + input.kind === 'character_action' + ? { prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [] } + : null, + characterId: input.kind === 'add_action' ? input.characterId.trim() : null, + outfitId: input.kind === 'add_action' ? input.outfitId.trim() : null, + characterSelectedAt: input.kind === 'add_action' ? createdAt : null, + actionInput: + input.kind === 'add_action' + ? { + id: createId(), + name: input.actionName.trim(), + type: input.actionType, + prompt: input.actionPrompt?.trim() || null, + fps: input.fps, + } + : null, + createdAt, + }, + ], + createdAt, + updatedAt: createdAt, + } +} + +function validateActionInput( + input: ConfigureWorkflowActionInput | Extract, +): void { + if (!input.actionName.trim()) throw new TypeError('动作名称不能为空') + if (!Number.isFinite(input.fps) || input.fps <= 0) throw new TypeError('FPS 必须大于 0') + if ('characterId' in input && (!input.characterId.trim() || !input.outfitId.trim())) { + throw new TypeError('追加动作必须绑定角色和造型') + } +} + +function requireStep(run: WorkflowRunSnapshot, type: WorkflowStepType): WorkflowStep { + const step = currentRevision(run).steps.find((item) => item.type === type) + if (!step) throw new Error(`WorkflowRun 缺少 ${type} 卡片`) + return step +} + +function requireActiveStep(run: WorkflowRunSnapshot): WorkflowStep { + const step = currentRevision(run).steps.find((item) => item.status === 'active') + if (!step) throw new Error('WorkflowRun 没有当前活动卡片') + return step +} + +function currentRevision(run: WorkflowRunSnapshot): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') + return revision +} + +function assertCurrentRevision(run: WorkflowRunSnapshot, revisionId: string): void { + if (run.currentRevisionId !== revisionId) { + throw new Error('WorkflowRun 已切换到新的 Revision,忽略旧分支的异步结果') + } +} + +function createRestartedStep( + source: WorkflowStep, + index: number, + restartIndex: number, + createId: () => string, +): WorkflowStep { + const step = structuredClone(source) + step.id = createId() + if (index < restartIndex) return step + + step.generations = [] + step.error = null + step.status = index === restartIndex ? 'active' : 'locked' + step.phase = + source.type === 'character' ? 'generating_character_candidates' : 'configuring_action' + return step +} + +function assertActive(run: WorkflowRunSnapshot): void { + if (run.status !== 'active') throw new Error('WorkflowRun 当前不能继续推进') +} + +function requireCharacterBinding(run: WorkflowRunSnapshot): { + characterId: string + outfitId: string +} { + const revision = currentRevision(run) + if (!revision.characterId || !revision.outfitId) { + throw new Error('WorkflowRun 尚未绑定角色和造型') + } + return { characterId: revision.characterId, outfitId: revision.outfitId } +} + +function requireActionInput(run: WorkflowRunSnapshot) { + const actionInput = currentRevision(run).actionInput + if (!actionInput) throw new Error('WorkflowRun 尚未配置动作') + return actionInput +} + +function generationIdsFor(step: WorkflowStep, role: WorkflowGenerationRole): string[] { + return step.generations.filter((item) => item.role === role).map((item) => item.taskId) +} + +function generationIdFor(step: WorkflowStep, role: WorkflowGenerationRole): string | null { + return generationIdsFor(step, role)[0] ?? null +} + +function requireCharacterCandidates(generation: Generation): CharacterTemplateGenerationResult { + if (generation.status === 'failed') throw new Error(generation.error || '角色候选生成失败') + if ( + generation.type !== 'character_template' || + generation.status !== 'completed' || + generation.result?.type !== 'character_template' || + generation.result.images.length !== CHARACTER_CANDIDATE_COUNT || + generation.result.images.some((image) => !image.url) + ) { + throw new Error(`角色生成必须返回 ${CHARACTER_CANDIDATE_COUNT} 张有效候选图`) + } + return generation.result +} + +function requireAnimation(generation: Generation): CompleteAnimationGenerationResult { + if (generation.status === 'failed') throw new Error(generation.error || '完整动画生成失败') + if ( + generation.type !== 'complete_animation' || + generation.status !== 'completed' || + generation.result?.type !== 'complete_animation' || + generation.result.frames.length === 0 || + generation.result.frames.some((frame) => !frame.url) + ) { + throw new Error('完整动画任务没有返回有效帧') + } + return generation.result +} + +function requireFirstFrame(generation: Generation): string { + if (generation.status === 'failed') throw new Error(generation.error || '首帧生成失败') + if ( + generation.type !== 'first_frame' || + generation.status !== 'completed' || + generation.result?.type !== 'first_frame' || + !generation.result.image.url + ) { + throw new Error('首帧生成未返回有效图片') + } + return generation.result.image.url +} + +function toCharacterBatch( + snapshot: WorkflowRunSnapshot, + generationId: string, + result: CharacterTemplateGenerationResult, +): CharacterCandidateBatch { + return { + snapshot: structuredClone(snapshot), + generationId, + candidates: result.images.map((image) => image.url), + } +} + +function waitForTerminal( + generationApis: GenerationApis, + generation: Generation, +): Promise { + if (generation.status === 'completed' || generation.status === 'failed') { + return Promise.resolve(generation) + } + return new Promise((resolve, reject) => { + let stop: () => void = () => undefined + let settled = false + const fail = (cause: unknown) => { + if (settled) return + settled = true + stop() + reject(asError(cause)) + } + const settleGeneration = (snapshot: Generation) => { + if (settled || (snapshot.status !== 'completed' && snapshot.status !== 'failed')) return + settled = true + stop() + resolve(snapshot) + } + const settleEvent = (event: GenerationEvent) => { + settleGeneration({ + id: event.taskId, + projectId: generation.projectId, + type: event.type, + status: event.status, + result: event.result, + error: event.error, + }) + } + try { + stop = generationApis.subscribe(generation.projectId, generation.id, settleEvent) + if (settled) return stop() + void generationApis.get(generation.projectId, generation.id).then(settleGeneration, fail) + } catch (cause) { + fail(cause) + } + }) +} + +function createRandomId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return `workflow-${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function asError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/frontend/src/entities/workflow-run/store/index.ts b/frontend/src/entities/workflow-run/store/index.ts new file mode 100644 index 0000000..c65cee1 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/index.ts @@ -0,0 +1,12 @@ +/** WorkflowRun 的异步持久化边界。 */ + +export { + createWorkflowRunRepository, + isWorkflowRunSnapshot, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +export type { + CreateWorkflowRunRepositoryOptions, + WorkflowRunRepository, +} from './workflow-run-store' diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts new file mode 100644 index 0000000..fbca64a --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' + +import type { WorkflowRunSnapshot } from '../model' +import { + createWorkflowRunRepository, + isWorkflowRunSnapshot, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' + +function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { + return { + id, + projectId: 'project-1', + source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, + status: 'active', + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + parentRevisionId: null, + restartedFromStepId: null, + createdAt: '2026-08-05T00:00:00.000Z', + steps: [ + { + id: 'step-character', + nodeId: 'character-node', + type: 'character', + status: 'active', + phase: 'generating_character_candidates', + generations: [], + error: null, + }, + { + id: 'step-action', + nodeId: 'action-node', + type: 'action', + status: 'locked', + phase: 'configuring_action', + generations: [], + error: null, + }, + ], + characterInput: { prompt: '像素骑士', referenceMedia: [] }, + characterId: null, + outfitId: null, + characterSelectedAt: null, + actionInput: null, + }, + ], + createdAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', + } +} + +function createMemoryStorage(initial: string | null = null) { + let value = initial + return { + getItem: () => value, + setItem: (_key: string, next: string) => { + value = next + }, + read: () => value, + } +} + +describe('WorkflowRunRepository', () => { + it('uses an asynchronous CRUD contract without change subscriptions', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + expect('subscribe' in repository).toBe(false) + expect('subscribeAll' in repository).toBe(false) + + const createdPromise = repository.create(createSnapshot()) + expect(createdPromise).toBeInstanceOf(Promise) + await createdPromise + await expect(repository.get('run-1')).resolves.toMatchObject({ id: 'run-1' }) + await expect(repository.list('project-1')).resolves.toHaveLength(1) + await expect(repository.save(createSnapshot())).resolves.toMatchObject({ id: 'run-1' }) + }) + + it('persists a versioned snapshot and hydrates it in a new repository', async () => { + const storage = createMemoryStorage() + const repository = createWorkflowRunRepository({ storage }) + await repository.create(createSnapshot()) + + expect(JSON.parse(storage.read()!)).toMatchObject({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ id: 'run-1' }], + }) + const restored = createWorkflowRunRepository({ storage }) + await expect(restored.get('run-1')).resolves.toEqual(createSnapshot()) + }) + + it('returns clones so callers cannot mutate persisted state without save', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + await repository.create(createSnapshot()) + const loaded = (await repository.get('run-1'))! + loaded.status = 'interrupted' + + await expect(repository.get('run-1')).resolves.toMatchObject({ status: 'active' }) + }) + + it('rejects duplicate creation and structurally invalid card phases', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + await repository.create(createSnapshot()) + await expect(repository.create(createSnapshot())).rejects.toThrow('已存在') + + const invalid = createSnapshot('run-invalid') + invalid.revisions[0]!.steps[0]!.phase = 'reviewing_animation' + expect(isWorkflowRunSnapshot(invalid)).toBe(false) + await expect(repository.save(invalid)).rejects.toThrow('Invalid WorkflowRun snapshot') + }) + + it('hydrates revision lineage only when parents and restart steps are valid', () => { + const snapshot = createSnapshot() + const parent = snapshot.revisions[0]! + const revision = { + id: 'revision-2', + parentRevisionId: parent.id, + restartedFromStepId: parent.steps[0]!.id, + createdAt: '2026-08-05T00:01:00.000Z', + steps: parent.steps.map((step, index) => ({ + ...structuredClone(step), + id: `step-v2-${index}`, + })), + characterInput: structuredClone(parent.characterInput), + characterId: parent.characterId, + outfitId: parent.outfitId, + characterSelectedAt: parent.characterSelectedAt, + actionInput: structuredClone(parent.actionInput), + } + snapshot.revisions.push(revision) + snapshot.currentRevisionId = revision.id + expect(isWorkflowRunSnapshot(snapshot)).toBe(true) + + revision.parentRevisionId = 'missing-revision' + expect(isWorkflowRunSnapshot(snapshot)).toBe(false) + }) + + it('ignores old or malformed local data instead of hydrating a partial run', async () => { + const storage = createMemoryStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION - 1, runs: [createSnapshot()] }), + ) + const repository = createWorkflowRunRepository({ storage }) + await expect(repository.list()).resolves.toEqual([]) + + const malformed = createMemoryStorage( + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ id: 'partial-run', projectId: 'project-1' }], + }), + ) + const malformedRepository = createWorkflowRunRepository({ storage: malformed }) + await expect(malformedRepository.list()).resolves.toEqual([]) + }) +}) diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts new file mode 100644 index 0000000..3bfbbce --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -0,0 +1,383 @@ +/** WorkflowRun 的异步持久化边界与当前 localStorage 适配器。 */ + +import type { + WorkflowGenerationRef, + WorkflowRevision, + WorkflowRunSnapshot, + WorkflowStep, +} from '../model' +import { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + WORKFLOW_GENERATION_ROLES, + WORKFLOW_RUN_KINDS, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_PHASES, + WORKFLOW_STEP_STATUSES, + WORKFLOW_STEP_TYPES, +} from '../model/constants' + +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 5 + +const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** + * 所有方法从一开始就是异步的,后续替换为 HTTP Repository 时调用方式不变。 + * 不提供 subscribe:Run 变化由发起操作的前端逻辑直接获知;后端任务进度由 Generation SSE 负责。 + */ +export interface WorkflowRunRepository { + create(run: WorkflowRunSnapshot): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + list(projectId?: string): Promise + save(run: WorkflowRunSnapshot): Promise +} + +export interface CreateWorkflowRunRepositoryOptions { + storage?: WorkflowRunStorage | null +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRunSnapshot[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +function isGenerationRef(value: unknown): value is WorkflowGenerationRef { + return ( + isRecord(value) && + isNonEmptyString(value.taskId) && + isMember(value.role, WORKFLOW_GENERATION_ROLES) + ) +} + +function phaseMatchesStep(step: WorkflowStep): boolean { + if (step.status === 'passed') return step.phase === 'completed' + if (step.type === 'character') { + return step.phase === 'generating_character_candidates' || step.phase === 'selecting_character' + } + return [ + 'configuring_action', + 'generating_action_candidates', + 'selecting_action_frame', + 'generating_animation', + 'reviewing_animation', + 'exporting_action', + ].includes(step.phase) +} + +function hasValidGenerationRefs(step: WorkflowStep): boolean { + const taskIds = step.generations.map((generation) => generation.taskId) + if (new Set(taskIds).size !== taskIds.length) return false + if (step.type === 'character') { + const count = step.generations.length + return ( + step.generations.every((item) => item.role === 'character_candidates') && + count <= 1 && + (step.phase === 'generating_character_candidates' || count === 1) + ) + } + + const candidateCount = step.generations.filter( + (item) => item.role === 'action_frame_candidate', + ).length + const animationCount = step.generations.filter((item) => item.role === 'animation').length + const rolesAreValid = + step.generations.every((item) => item.role !== 'character_candidates') && + candidateCount <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && + animationCount <= 1 + if (!rolesAreValid) return false + if (step.phase === 'configuring_action') return candidateCount === 0 && animationCount === 0 + if (step.phase === 'generating_action_candidates') return animationCount === 0 + if (step.phase === 'selecting_action_frame') { + return candidateCount === ACTION_FIRST_FRAME_CANDIDATE_COUNT && animationCount === 0 + } + return candidateCount === ACTION_FIRST_FRAME_CANDIDATE_COUNT && animationCount === 1 +} + +function isWorkflowStep(value: unknown, expectedType: string): value is WorkflowStep { + if ( + !isRecord(value) || + !isNonEmptyString(value.id) || + !isNonEmptyString(value.nodeId) || + value.type !== expectedType || + !isMember(value.type, WORKFLOW_STEP_TYPES) || + !isMember(value.status, WORKFLOW_STEP_STATUSES) || + !isMember(value.phase, WORKFLOW_STEP_PHASES) || + !Array.isArray(value.generations) || + !value.generations.every(isGenerationRef) || + !isNullableString(value.error) + ) { + return false + } + + const step = value as unknown as WorkflowStep + const errorIsValid = step.status === 'failed' ? isNonEmptyString(step.error) : step.error === null + return errorIsValid && phaseMatchesStep(step) && hasValidGenerationRefs(step) +} + +function hasValidStepLine( + steps: WorkflowStep[], + expectedOrder: readonly string[], + rootNodeId: string, +): boolean { + if ( + steps.length !== expectedOrder.length || + !steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) || + new Set(steps.map((step) => step.id)).size !== steps.length || + new Set(steps.map((step) => step.nodeId)).size !== steps.length || + rootNodeId !== steps[0]?.nodeId + ) { + return false + } + + if (steps.every((step) => step.status === 'passed')) return true + + const currentIndex = steps.findIndex( + (step) => step.status === 'active' || step.status === 'failed', + ) + if (currentIndex < 0) return false + + return steps.every((step, index) => { + if (index < currentIndex) return step.status === 'passed' + if (index === currentIndex) return true + return step.status === 'locked' + }) +} + +function isWorkflowRevision( + value: unknown, + expectedOrder: readonly string[], + rootNodeId: string, + runKind: WorkflowRunSnapshot['source']['key'], +): value is WorkflowRevision { + return ( + isRecord(value) && + isNonEmptyString(value.id) && + isNullableString(value.parentRevisionId) && + isNullableString(value.restartedFromStepId) && + Array.isArray(value.steps) && + hasValidStepLine(value.steps as WorkflowStep[], expectedOrder, rootNodeId) && + hasValidInputs(value as unknown as WorkflowRevision, runKind) && + isNonEmptyString(value.createdAt) + ) +} + +function hasValidRevisions(run: WorkflowRunSnapshot): boolean { + const expectedOrder = WORKFLOW_STEP_ORDERS[run.source.key] + if ( + run.revisions.length === 0 || + !run.revisions.every((revision) => + isWorkflowRevision(revision, expectedOrder, run.source.rootNodeId, run.source.key), + ) || + new Set(run.revisions.map((revision) => revision.id)).size !== run.revisions.length || + new Set(run.revisions.flatMap((revision) => revision.steps.map((step) => step.id))).size !== + run.revisions.length * expectedOrder.length + ) { + return false + } + + const current = run.revisions.find((revision) => revision.id === run.currentRevisionId) + if (!current) return false + const currentStatuses = current.steps.map((step) => step.status) + if (run.status === 'completed' && !currentStatuses.every((status) => status === 'passed')) { + return false + } + if (run.status === 'failed' && !currentStatuses.includes('failed')) return false + if ( + (run.status === 'active' || run.status === 'interrupted') && + !currentStatuses.includes('active') + ) { + return false + } + + return run.revisions.every((revision, index) => { + if (index === 0) { + return revision.parentRevisionId === null && revision.restartedFromStepId === null + } + const parentIndex = run.revisions.findIndex( + (candidate) => candidate.id === revision.parentRevisionId, + ) + if (parentIndex < 0 || parentIndex >= index || revision.restartedFromStepId === null) + return false + return run.revisions[parentIndex]!.steps.some( + (step) => step.id === revision.restartedFromStepId, + ) + }) +} + +function isMediaReference(value: unknown): boolean { + // MediaReference 在 Entity 层是品牌字符串;Repository 只校验可持久化表示。 + return isNonEmptyString(value) +} + +function hasValidInputs( + revision: WorkflowRevision, + runKind: WorkflowRunSnapshot['source']['key'], +): boolean { + const characterInput = revision.characterInput as unknown + const characterInputValid = + characterInput === null || + (isRecord(characterInput) && + isNonEmptyString(characterInput.prompt) && + Array.isArray(characterInput.referenceMedia) && + characterInput.referenceMedia.every(isMediaReference)) + if (!characterInputValid) return false + + const characterIsEmpty = + revision.characterId === null && + revision.outfitId === null && + revision.characterSelectedAt === null + const characterIsSelected = + isNonEmptyString(revision.characterId) && + isNonEmptyString(revision.outfitId) && + isNonEmptyString(revision.characterSelectedAt) + if (!characterIsEmpty && !characterIsSelected) return false + + const actionInput = revision.actionInput as unknown + const actionValid = + actionInput === null || + (isRecord(actionInput) && + isNonEmptyString(actionInput.id) && + isNonEmptyString(actionInput.name) && + isMember(actionInput.type, ACTION_TYPES) && + isNullableString(actionInput.prompt) && + Number.isFinite(actionInput.fps) && + typeof actionInput.fps === 'number' && + actionInput.fps > 0) + if (!actionValid) return false + + if (runKind === 'character_action') { + if (revision.characterInput === null) return false + if (revision.actionInput !== null && !characterIsSelected) return false + } else if ( + revision.characterInput !== null || + !characterIsSelected || + revision.actionInput === null + ) { + return false + } + + const completed = revision.steps.every((step) => step.status === 'passed') + return !completed || (characterIsSelected && revision.actionInput !== null) +} + +export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnapshot { + if ( + !isRecord(value) || + !isNonEmptyString(value.id) || + !isNonEmptyString(value.projectId) || + !isRecord(value.source) || + value.source.type !== 'builtin' || + !isMember(value.source.key, WORKFLOW_RUN_KINDS) || + !isNonEmptyString(value.source.rootNodeId) || + !isMember(value.status, WORKFLOW_RUN_STATUSES) || + !isNonEmptyString(value.currentRevisionId) || + !Array.isArray(value.revisions) || + !isNonEmptyString(value.createdAt) || + !isNonEmptyString(value.updatedAt) + ) { + return false + } + + const run = value as unknown as WorkflowRunSnapshot + return hasValidRevisions(run) +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRunSnapshot[] { + if (storage === null) return [] + try { + const raw = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (!raw) return [] + const value: unknown = JSON.parse(raw) + if ( + !isRecord(value) || + value.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(value.runs) || + !value.runs.every(isWorkflowRunSnapshot) + ) { + return [] + } + return structuredClone(value.runs) + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage + } catch { + return null + } +} + +/** 当前本地实现;业务层只依赖异步 Repository 接口。 */ +export function createWorkflowRunRepository( + options: CreateWorkflowRunRepositoryOptions = {}, +): WorkflowRunRepository { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + + function persist(): void { + const payload: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(payload)) + } + + async function write( + run: WorkflowRunSnapshot, + requireNew: boolean, + ): Promise { + if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') + if (requireNew && runs.has(run.id)) throw new Error(`WorkflowRun 已存在:${run.id}`) + const saved = structuredClone(run) + const previous = runs.get(saved.id) + runs.set(saved.id, saved) + try { + persist() + } catch (cause) { + if (previous === undefined) runs.delete(saved.id) + else runs.set(previous.id, previous) + throw new Error('WorkflowRun 本地持久化失败', { cause }) + } + return structuredClone(saved) + } + + return { + create: (run) => write(run, true), + async get(runId) { + const run = runs.get(runId) + return run ? structuredClone(run) : null + }, + async list(projectId) { + return [...runs.values()] + .filter((run) => projectId === undefined || run.projectId === projectId) + .map((run) => structuredClone(run)) + }, + save: (run) => write(run, false), + } +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce879..63dabe7 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -2,66 +2,34 @@ import type { CreateWorkflowRunInput, WorkflowRevision, WorkflowRun, + WorkflowRunSnapshot, WorkflowStep, } from '@/entities' -/** 更新当前 Revision 中某个步骤的业务数据。 */ +/** 更新某张编辑器卡片的业务输入。 */ export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] - data: unknown -} - -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { revisionId: WorkflowRevision['id'] stepId: WorkflowStep['id'] + data: unknown } -/** 把某次服务端调用的结果写回目标步骤。 */ +/** 把异步服务端结果交还给发起它的 Run 和卡片。 */ export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ + runId: WorkflowRunSnapshot['id'] + /** 旧 Revision 的异步结果不能写入当前新分支。 */ revisionId: WorkflowRevision['id'] stepId: WorkflowStep['id'] result: unknown } /** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 + * WorkflowController 后续负责编排 WorkflowDefinition 中的卡片。 + * 从根节点发起的是新 Run;在同一 Run 中从历史卡片重做会追加 WorkflowRevision。 + * WorkflowDefinition 的定义版本仍是另一层概念,不能拿执行 Revision 代替。 */ export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程快照。 */ - getWorkflow(): WorkflowRun - - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise - - /** 连续推进到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise - - /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise - - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ - interrupt(): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + applyServerResult(input: ApplyServerResultInput): Promise + updateStep(input: UpdateWorkflowStepInput): Promise }