diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359d..1075fc2 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,10 +61,26 @@ 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, + createWorkflowRunService, + createWorkflowRunStore, + WORKFLOW_STEP_ORDERS, +} from './workflow-run' export type { + ActionFirstFrameCandidateBatch, + CreateWorkflowRunStoreOptions, + CreateWorkflowRunServiceOptions, CreateWorkflowRunInput, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, ExportStatus, GenerationStatus, WorkflowDriver, @@ -68,6 +90,11 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, + WorkflowRunService, WorkflowRunPurpose, WorkflowRunStatus, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b..2ae8a5b 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,157 +1,41 @@ -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[] -} - -/** - * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 + * WorkflowRun Entity 的对外入口。 * - * 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 能力,不绕过入口直接依赖 model/store + * 内部文件。这样既保留了子目录的职责分工,又不把内部结构变成全仓库 API。 + */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './model' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, +} from './model' +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' +export { createWorkflowRunService } from './service' +export type { + ActionFirstFrameCandidateBatch, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, + CreateWorkflowRunServiceOptions, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + 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..375bda3 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -0,0 +1,63 @@ +/** + * WorkflowRun 的业务词汇和步骤模板。 + * + * 常量数组同时服务于三个地方:TypeScript 联合类型、运行时水合校验、 + * 以及页面的进度顺序。只保留一份定义,可以避免“类型说可以,恢复时却拒绝”。 + */ + +/** 该 Run 是由 AI 自动引导,还是用户在编辑器中手动推进。 */ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const + +/** + * 一个 Run 只有一个目标。新建角色和追加动作可在同一界面连续操作, + * 但是两次独立任务,因此使用两个 WorkflowRun。 + */ +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const + +/** Run 级状态:描述整个用户任务,不等于某次后端生成任务的状态。 */ +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const + +/** + * Revision 级状态。用户从旧步骤重做时,旧 Revision 变为 abandoned, + * 并追加新 Revision;不覆盖历史,才能说清“这个结果从哪次重做而来”。 + */ +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const + +/** 当前 Revision 中生成阶段的汇总状态,不是单个 GenerationTask.status。 */ +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const + +/** 导出阶段的汇总状态;角色生成 Run 没有导出步骤时保持 not_exported。 */ +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const + +/** + * 单个步骤的状态。locked 表示前置条件未满足,available 表示可开始, + * active 表示当前正在处理,passed/failed 是已结束结果。 + */ +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 角色形象每次生成 4 张临时候选;用户只会确认其中 1 张为正式资产。 */ +export const CHARACTER_CANDIDATE_COUNT = 4 + +/** 动作也先生成 4 张独立首帧,避免错误姿势直接扩展成完整动画。 */ +export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 + +/** + * 按任务目的分开定义步骤顺序。 + * + * create_character 到“四选一并保存正式角色”就结束; + * add_action 从已有角色/造型开始,不重复跑角色母版生成。 + * + * 两个 Run 可以由同一页面连续展示,但数据上必须拆开,否则历史记录、 + * 失败重试和后续追加动作都无法准确归属。 + */ +export const WORKFLOW_STEP_ORDERS = { + create_character: ['character-setup', 'character-template', 'template-candidate'], + add_action: [ + 'action-setup', + 'first-frame', + 'first-frame-candidate', + 'complete-animation', + 'review', + 'export', + ], +} 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..fe954e6 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -0,0 +1,27 @@ +/** + * WorkflowRun 领域模型的子目录入口。 + * + * 本目录只定义“WorkflowRun 是什么”:业务词汇、步骤模板、Run/Revision/Step + * 类型以及创建输入。它不知道 localStorage、订阅者或页面,因此可被 + * Store、Controller 和页面共同依赖,而不产生反向依赖。 + */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowStep, + 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..efda404 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -0,0 +1,205 @@ +/** + * WorkflowRun Entity 的公开业务模型。 + * + * 层级关系是 WorkflowRun(一次用户任务) -> WorkflowRevision(一条重做版本) + * -> WorkflowStep(版本中的一个步骤)。后端 Generation 只是某个步骤引用的异步任务, + * 不能代替 WorkflowRun;角色和动作是最终资产,也不应嵌进运行历史。 + */ + +import type { Generation } from '../../generation' +import type { ActionType } from '../../character' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' + +/** ai/manual 表示运行由哪种交互方式推进,不改变后端数据契约。 */ +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] + +/** Run 的用户目标,也是选择步骤模板和校验资产引用的判别字段。 */ +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] + +/** 从两套步骤模板自动推导,避免类型与运行顺序手工维护两份。 */ +export type WorkflowStepType = + (typeof WORKFLOW_STEP_ORDERS)[keyof typeof WORKFLOW_STEP_ORDERS][number] +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] +export type ExportStatus = (typeof EXPORT_STATUSES)[number] + +/** + * 一次流程步骤的可恢复快照,不包含页面显示状态。 + * + * 此处故意没有通用 input/output:四张角色候选是后端临时文件,如果把 URL 塞入 + * localStorage,候选删除后就会留下无效历史。可恢复信息通过 taskId、正式资产 ID + * 和 referenceStepIds 表达,候选预览数组只存在当前界面/请求缓存中。 + */ +export interface WorkflowStep { + /** 前端步骤快照 ID,用于 Revision 之间引用;它不是后端 task ID。 */ + id: string + /** 步骤业务类型;必须与当前 purpose 对应的模板位置一致。 */ + type: WorkflowStepType + /** 当前步骤在前端编排中的生命周期。 */ + status: WorkflowStepStatus + /** + * 已由后端接受的 Generation ID。步骤 passed/failed 后仍保留, + * 方便历史查询和问题定位;它只是引用,不复制后端生成结果。 + */ + taskId: Generation['id'] | null + /** + * `first-frame` 一次需要 4 个独立生成任务,所以单独保存它们的 ID。 + * 其他步骤必须保持空数组;候选图 URL 仍不进入快照。 + */ + candidateTaskIds: Generation['id'][] + /** + * 请求已发出、但后端 taskId 尚未返回时的本地防重标识。 + * taskId 返回后必须清空,两者不能同时存在。 + */ + submissionId: string | null + /** 失败步骤必须提供原因,其他状态必须为 null。 */ + error: string | null + /** 新版本沿用的历史步骤,用于解释版本来源。 */ + referenceStepIds: string[] +} + +/** + * 一条可回看的任务执行版本。 + * + * 用户目标不变,只是从某个已通过步骤重做时,在同一 Run 下追加 Revision。 + * 网络重试不创建 Revision;用户改成另一个动作目标时则创建新 Run。 + */ +export interface WorkflowRevision { + /** 本版本 ID。 */ + id: string + /** 首版为 null;重做版本指向它沿用的旧 Revision。 */ + basedOnRevisionId: string | null + /** 首版为 null;重做时记录从旧 Revision 的哪个 passed 步骤重开。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 两种任务共享的运行字段。 + * Run 是历史列表的主体;Revision 是 Run 内部的重做记录,不单独伪装成新任务。 + */ +interface WorkflowRunBase { + /** 一次用户任务的稳定 ID,重做时不变。 */ + id: string + /** 所属项目;历史记录和恢复查询均按项目隔离。 */ + projectId: string + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ + currentRevisionId: string + /** 按创建时间排列;历史版本只读,重开时追加新版本。 */ + revisions: WorkflowRevision[] + /** 用户本次任务的目标描述;界面文案不存在这里。 */ + prompt: string | null + /** Run 创建时间,用于历史排序。 */ + createdAt: string + /** 任何可持久业务状态最后更新的时间。 */ + updatedAt: string +} + +/** + * 一次前端创作任务。当前由前端推进并用 localStorage 恢复,不伪装成已有后端持久化。 + * + * create_character 的两个分支表达同一个生命周期:生成中时还没有正式资产 ID; + * 用户从 4 张候选中选择 1 张且后端保存成功后,才同时写入 characterId、 + * outfitId 和 selectedAt。其余 3 张由后端清理,不进入 WorkflowRun。 + * + * add_action 是另一个 Run,只在用户点击“生成动作”时创建, + * 因此必须从开始就绑定已有 characterId 和 outfitId。它会先生成 + * 4 个独立首帧任务,用户选中 1 张后才进入完整动画生成。 + */ +export type WorkflowRun = WorkflowRunBase & + ( + | { + purpose: 'create_character' + characterId: null + outfitId: null + selectedAt: null + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'create_character' + characterId: string + outfitId: string + /** 选中图片已保存为正式角色资产的时间。 */ + selectedAt: string + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + selectedAt?: never + /** Run 创建时就固定的动作资产 ID,保证审核/发布重试幂等。 */ + actionId: string + /** 用户这次要创建的动作名称,刷新后仍用于正式写入资产。 */ + actionName: string + /** 动作的业务语义,不由生成结果反向猜测。 */ + actionType: ActionType + /** 最终动作资产的默认播放帧率。 */ + fps: number + } + ) + +interface CreateWorkflowRunInputBase { + /** 任务所属项目,不允许空字符串。 */ + projectId: string + /** 由 Quick Start 自动推进,或由工作流编辑器手动推进。 */ + driver: WorkflowDriver + /** 用户任务描述;Store 会去掉首尾空白,空文本按 null 保存。 */ + prompt?: string +} + +/** + * 创建 Run 的判别联合输入。 + * + * 创建角色时尚无资产 ID,所以类型明确禁止传入 characterId/outfitId; + * 追加动作必须定位已有角色的具体造型,所以两个 ID 缺一不可。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + actionName: string + actionType: ActionType + 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..72bf39b --- /dev/null +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -0,0 +1,20 @@ +/** + * WorkflowRun 可执行用例的子目录入口。 + * + * model 只定义数据,store 只管快照,service 负责组合真实 Character/Generation + * 端口完成角色和动作任务。页面应调用这些用例,不自行改写 Run。 + */ + +export { createWorkflowRunService } from './workflow-run-service' +export type { + ActionFirstFrameCandidateBatch, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, + CreateWorkflowRunServiceOptions, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + 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..305a7b7 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -0,0 +1,322 @@ +/** WorkflowRun Service 的真实用例链测试,不用伪造的页面成功状态代替端口结果。 */ + +import { describe, expect, it, vi } from 'vitest' + +import type { Character, CharacterApis } from '../../character' +import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import { createWorkflowRunStore } from '../store' +import { + createWorkflowRunService, + type CharacterCandidateConfirmationApis, +} from './workflow-run-service' + +function createCharacter(): Character { + return { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00: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 task + }, + subscribe() { + return () => undefined + }, + } + return { apis, create, tasks } +} + +function createService() { + let id = 0 + let timestamp = 0 + const store = createWorkflowRunStore({ + storage: null, + createId: () => `workflow-id-${++id}`, + now: () => `2026-08-03T00:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + const generation = createGenerationApis() + let character = createCharacter() + const characterApis: CharacterApis = { + get: vi.fn(async () => { + return structuredClone(character) + }), + async listByProject() { + return [structuredClone(character)] + }, + async create() { + return structuredClone(character) + }, + update: vi.fn(async (next: Character) => { + character = structuredClone(next) + return structuredClone(character) + }), + } + const confirmSelection = vi.fn(async () => ({ + character: structuredClone(character), + outfitId: 'outfit-1', + })) + const candidateConfirmationApis: CharacterCandidateConfirmationApis = { confirmSelection } + const service = createWorkflowRunService({ + store, + generationApis: generation.apis, + characterApis, + candidateConfirmationApis, + now: () => `2026-08-03T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + return { service, store, generation, characterApis, confirmSelection } +} + +describe('createWorkflowRunService', () => { + it('runs character selection and action publishing as two linked user tasks', async () => { + const { service, store, generation, characterApis, confirmSelection } = createService() + + const candidates = await service.startCharacter({ + projectId: 'project-1', + prompt: '一位像素风守夜人', + driver: 'ai', + }) + + expect(candidates.candidates).toEqual([ + 'candidate-1.png', + 'candidate-2.png', + 'candidate-3.png', + 'candidate-4.png', + ]) + expect(candidates.run.purpose).toBe('create_character') + expect( + candidates.run.revisions[0]?.steps.find((step) => step.type === 'template-candidate')?.status, + ).toBe('active') + expect(JSON.stringify(store.get(candidates.run.id))).not.toContain('candidate-1.png') + + const characterRun = await service.confirmCharacter({ + runId: candidates.run.id, + selectedImageUrl: 'candidate-2.png', + }) + expect(characterRun).toMatchObject({ + purpose: 'create_character', + status: 'completed', + characterId: 'character-1', + outfitId: 'outfit-1', + }) + expect(confirmSelection).toHaveBeenCalledWith({ + projectId: 'project-1', + generationId: 'generation-1', + selectedImageUrl: 'candidate-2.png', + description: '一位像素风守夜人', + }) + + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '向前行走', + actionType: 'walk', + prompt: '轻快地向前行走', + fps: 12, + driver: 'ai', + }) + + expect(firstFrames.run.id).not.toBe(characterRun.id) + expect(firstFrames.run.purpose).toBe('add_action') + expect(firstFrames.candidates).toEqual([ + 'first-frame-generation-2.png', + 'first-frame-generation-3.png', + 'first-frame-generation-4.png', + 'first-frame-generation-5.png', + ]) + expect( + firstFrames.run.revisions[0]?.steps.find((step) => step.type === 'first-frame-candidate') + ?.status, + ).toBe('active') + expect(JSON.stringify(store.get(firstFrames.run.id))).not.toContain('first-frame-generation') + expect(generation.create).toHaveBeenCalledTimes(5) + + const actionRun = await service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: firstFrames.candidates[1]!, + }) + expect(actionRun.revisions[0]?.steps.find((step) => step.type === 'review')?.status).toBe( + 'active', + ) + expect(generation.create).toHaveBeenCalledTimes(6) + + const published = await service.approveAction(actionRun.id) + expect(published.run.status).toBe('completed') + expect(published.actionId).toBe(actionRun.actionId) + expect(published.character.outfits[0]?.actions[0]).toMatchObject({ + id: actionRun.actionId, + name: '向前行走', + type: 'walk', + fps: 12, + }) + expect(published.character.outfits[0]?.actions[0]?.frames).toHaveLength(2) + expect(characterApis.update).toHaveBeenCalledTimes(1) + }) + + it('rejects a candidate that was not returned by the current generation task', async () => { + const { service, confirmSelection } = createService() + const batch = await service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'manual', + }) + + await expect( + service.confirmCharacter({ runId: batch.run.id, selectedImageUrl: 'foreign.png' }), + ).rejects.toThrow('选中图片不属于当前角色生成任务') + expect(confirmSelection).not.toHaveBeenCalled() + }) + + it('does not complete the character run when backend confirmation fails', async () => { + const fixture = createService() + fixture.confirmSelection.mockRejectedValueOnce(new Error('后端候选确认失败')) + const batch = await fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'ai', + }) + + await expect( + fixture.service.confirmCharacter({ + runId: batch.run.id, + selectedImageUrl: 'candidate-1.png', + }), + ).rejects.toThrow('后端候选确认失败') + expect(fixture.store.get(batch.run.id)?.status).toBe('active') + }) + + it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { + const { service, store, generation } = createService() + const run = store.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '向前行走', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + const restored = structuredClone(run) + const revision = restored.revisions[0]! + revision.steps[0]!.status = 'passed' + revision.steps[1]!.status = 'active' + revision.steps[1]!.candidateTaskIds = ['persisted-first-frame-1', 'persisted-first-frame-2'] + revision.generationStatus = 'in_progress' + store.save(restored) + for (const index of [1, 2]) { + generation.tasks.set(`persisted-first-frame-${index}`, { + id: `persisted-first-frame-${index}`, + projectId: 'project-1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: `restored-first-frame-${index}.png` } }, + error: null, + }) + } + + const resumed = await service.resumeActionFirstFrameCandidates(run.id) + + expect(generation.create).toHaveBeenCalledTimes(2) + expect(resumed.candidates).toHaveLength(4) + expect(resumed.candidates.slice(0, 2)).toEqual([ + 'restored-first-frame-1.png', + 'restored-first-frame-2.png', + ]) + expect(resumed.run.revisions[0]?.steps[2]?.type).toBe('first-frame-candidate') + expect(resumed.run.revisions[0]?.steps[2]?.status).toBe('active') + }) + + it('restores an action already in review without rerunning generation', async () => { + const { service, generation, characterApis } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + const actionRun = await service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: firstFrames.candidates[0]!, + }) + expect(generation.create).toHaveBeenCalledTimes(5) + expect(characterApis.get).toHaveBeenCalledTimes(2) + + const resumed = await service.resumeAction(actionRun.id) + + expect(resumed).toEqual(actionRun) + expect(generation.create).toHaveBeenCalledTimes(5) + expect(characterApis.get).toHaveBeenCalledTimes(2) + }) + + it('rejects a first-frame image that is not one of the four current candidates', async () => { + const { service, generation } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + + await expect( + service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: 'foreign-first-frame.png', + }), + ).rejects.toThrow('选中图片不属于当前动作首帧任务') + expect(generation.create).toHaveBeenCalledTimes(4) + }) +}) 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..3bd5125 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -0,0 +1,706 @@ +/** + * WorkflowRun 的可执行前端用例。 + * + * Store 只保存快照,本 Service 才真正组合 Generation/Character 端口完成业务: + * 生成 4 张角色候选、确认 1 张为正式角色、创建独立动作 Run、 + * 生成 4 张动作首帧候选、根据选中首帧生成完整动画, + * 并在审核后写入角色资产。 + */ + +import type { Action, ActionType, Character, CharacterApis, Frame } from '../../character' +import type { + CharacterTemplateGenerationResult, + CompleteAnimationGenerationResult, + Generation, + GenerationApis, + GenerationEvent, +} from '../../generation' +import type { MediaReference } from '../../media' +import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' +import type { WorkflowRevision, WorkflowRun, WorkflowStep, WorkflowStepType } from '../model' +import type { WorkflowRunStore } from '../store' + +/** + * 确认角色候选的后端原子操作。 + * + * 后端必须在同一用例中保存选中图、返回正式角色/造型 ID, + * 并安排清理同一 generationId 下的其余 3 张候选。 + * 前端不能用“先创建角色、再单独删图”的两步请求伪装原子性。 + */ +export interface CharacterCandidateConfirmationApis { + confirmSelection(input: { + projectId: string + generationId: string + selectedImageUrl: string + description: string + }): Promise<{ character: Character; outfitId: string }> +} + +export interface StartCharacterRunInput { + projectId: string + prompt: string + driver: 'ai' | 'manual' + referenceMedia?: readonly MediaReference[] +} + +export interface CharacterCandidateBatch { + run: WorkflowRun + generationId: string + /** 仅供当前选择界面使用,不写入 WorkflowRun/localStorage。 */ + candidates: readonly string[] +} + +export interface ConfirmCharacterSelectionInput { + runId: string + selectedImageUrl: string +} + +export interface StartActionRunInput { + projectId: string + characterId: string + outfitId: string + actionName: string + actionType: ActionType + prompt?: string | null + fps: number + driver: 'ai' | 'manual' +} + +export interface ActionFirstFrameCandidateBatch { + run: WorkflowRun + /** 4 张图分别对应 4 个后端 Generation,顺序与 candidateTaskIds 一致。 */ + candidateTaskIds: readonly string[] + /** 仅供当前首帧选择界面使用,不写入 WorkflowRun/localStorage。 */ + candidates: readonly string[] +} + +export interface ConfirmActionFirstFrameInput { + runId: string + selectedImageUrl: string +} + +export interface PublishActionResult { + run: WorkflowRun + character: Character + characterId: string + outfitId: string + actionId: string +} + +export interface WorkflowRunService { + startCharacter(input: StartCharacterRunInput): Promise + resumeCharacterCandidates(runId: string): Promise + confirmCharacter(input: ConfirmCharacterSelectionInput): Promise + startAction(input: StartActionRunInput): Promise + resumeActionFirstFrameCandidates(runId: string): Promise + confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise + resumeAction(runId: string): Promise + approveAction(runId: string): Promise +} + +export interface CreateWorkflowRunServiceOptions { + store: WorkflowRunStore + generationApis: GenerationApis + characterApis: CharacterApis + candidateConfirmationApis: CharacterCandidateConfirmationApis + now?: () => string +} + +export function createWorkflowRunService({ + store, + generationApis, + characterApis, + candidateConfirmationApis, + now = () => new Date().toISOString(), +}: CreateWorkflowRunServiceOptions): WorkflowRunService { + async function startCharacter(input: StartCharacterRunInput): Promise { + const prompt = input.prompt.trim() + if (!prompt) throw new Error('请先描述想要创建的角色') + + let run = store.create({ + projectId: input.projectId, + purpose: 'create_character', + driver: input.driver, + prompt, + }) + run = advanceStep(run, 'character-setup', 'character-template', now()) + store.save(run) + + try { + const generation = await generationApis.create({ + type: 'character_template', + projectId: run.projectId, + prompt, + referenceMedia: input.referenceMedia ?? [], + }) + run = recordTask(run, 'character-template', generation.id, now()) + store.save(run) + const terminal = await waitForTerminal(generationApis, generation) + const result = requireCharacterCandidates(terminal) + run = completeGenerationStep( + requireRun(store, run.id), + 'character-template', + 'template-candidate', + now(), + ) + store.save(run) + return toCandidateBatch(run, terminal.id, result) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '角色候选生成失败'), now()) + throw asError(cause) + } + } + + async function resumeCharacterCandidates(runId: string): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'create_character') throw new Error('该 WorkflowRun 不是角色生成任务') + const templateStep = requireStep(run, 'character-template') + if (!templateStep.taskId) throw new Error('角色生成任务 ID 不存在,无法恢复候选') + + const terminal = await waitForTerminal( + generationApis, + await generationApis.get(run.projectId, templateStep.taskId), + ) + const result = requireCharacterCandidates(terminal) + if (templateStep.status === 'active') { + run = completeGenerationStep(run, 'character-template', 'template-candidate', now()) + store.save(run) + } + return toCandidateBatch(run, terminal.id, result) + } + + async function confirmCharacter(input: ConfirmCharacterSelectionInput): Promise { + const batch = await resumeCharacterCandidates(input.runId) + if (!batch.candidates.includes(input.selectedImageUrl)) { + throw new Error('选中图片不属于当前角色生成任务') + } + const run = batch.run + if (run.status !== 'active' || requireStep(run, 'template-candidate').status !== 'active') { + throw new Error('当前 WorkflowRun 不在候选确认阶段') + } + + const confirmed = await candidateConfirmationApis.confirmSelection({ + projectId: run.projectId, + generationId: batch.generationId, + selectedImageUrl: input.selectedImageUrl, + description: run.prompt ?? '', + }) + const outfit = confirmed.character.outfits.find((item) => item.id === confirmed.outfitId) + if ( + confirmed.character.projectId !== run.projectId || + !confirmed.character.id.trim() || + !outfit || + outfit.characterId !== confirmed.character.id + ) { + throw new Error('候选确认接口没有返回有效的角色与造型') + } + + const selectedAt = now() + const completed = editCurrentRevision(run, selectedAt, (revision) => { + const candidate = revision.steps.find((step) => step.type === 'template-candidate')! + candidate.status = 'passed' + revision.status = 'completed' + revision.generationStatus = 'completed' + }) as WorkflowRun + if (completed.purpose !== 'create_character') { + throw new Error('角色确认过程中 WorkflowRun 目的发生了变化') + } + const result: WorkflowRun = { + ...completed, + purpose: 'create_character', + status: 'completed', + characterId: confirmed.character.id, + outfitId: confirmed.outfitId, + selectedAt, + updatedAt: selectedAt, + } + store.save(result) + return result + } + + async function startAction(input: StartActionRunInput): Promise { + if (!input.actionName.trim()) throw new Error('请先填写动作名称') + if (!Number.isFinite(input.fps) || input.fps <= 0) throw new Error('FPS 必须大于 0') + + // 在创建 Run 前校验正式角色,避免错误 ID 留下永远无法继续的空历史。 + const characterImageUrl = await loadCharacterImage( + input.projectId, + input.characterId, + input.outfitId, + ) + + let run = store.create({ + projectId: input.projectId, + purpose: 'add_action', + driver: input.driver, + prompt: input.prompt?.trim() || undefined, + characterId: input.characterId, + outfitId: input.outfitId, + actionName: input.actionName.trim(), + actionType: input.actionType, + fps: input.fps, + }) + run = advanceStep(run, 'action-setup', 'first-frame', now()) + store.save(run) + + try { + return await collectActionFirstFrameCandidates(run.id, characterImageUrl) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选生成失败'), now()) + throw asError(cause) + } + } + + async function resumeActionFirstFrameCandidates( + runId: string, + ): Promise { + const run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active') throw new Error('动作任务已经结束,无法恢复首帧候选') + const characterImageUrl = await loadCharacterImage(run.projectId, run.characterId, run.outfitId) + try { + return await collectActionFirstFrameCandidates(run.id, characterImageUrl) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选恢复失败'), now()) + throw asError(cause) + } + } + + async function loadCharacterImage( + projectId: string, + characterId: string, + outfitId: string, + ): Promise { + const character = await characterApis.get(characterId) + if (character.projectId !== projectId) throw new Error('动作角色不属于当前项目') + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit || outfit.characterId !== characterId) throw new Error('动作所属角色造型不存在') + if (!outfit.characterTemplateUrl) throw new Error('正式角色造型没有可用的角色图') + return outfit.characterTemplateUrl + } + + async function collectActionFirstFrameCandidates( + runId: string, + characterImageUrl: string, + ): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + let firstFrameStep = requireStep(run, 'first-frame') + + if (firstFrameStep.status === 'active') { + while (firstFrameStep.candidateTaskIds.length < ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + if (run.purpose !== 'add_action') { + throw new Error('动作首帧生成过程中 WorkflowRun 目的发生了变化') + } + const task = await generationApis.create({ + type: 'first_frame', + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + actionType: run.actionType, + prompt: run.prompt, + referenceMedia: [characterImageUrl as MediaReference], + }) + run = appendCandidateTask(run, 'first-frame', task.id, now()) + store.save(run) + firstFrameStep = requireStep(run, 'first-frame') + } + } else if ( + firstFrameStep.status !== 'passed' || + requireStep(run, 'first-frame-candidate').status !== 'active' + ) { + throw new Error('当前 WorkflowRun 不在动作首帧选择阶段') + } + + const taskIds = requireStep(run, 'first-frame').candidateTaskIds + const terminals = await Promise.all( + taskIds.map(async (taskId) => + waitForTerminal(generationApis, await generationApis.get(run.projectId, taskId)), + ), + ) + const candidates = terminals.map(requireFirstFrame) + if (firstFrameStep.status === 'active') { + run = completeGenerationStep(run, 'first-frame', 'first-frame-candidate', now()) + store.save(run) + } + return { run, candidateTaskIds: taskIds, candidates } + } + + async function confirmActionFirstFrame( + input: ConfirmActionFirstFrameInput, + ): Promise { + const batch = await resumeActionFirstFrameCandidates(input.runId) + if (!batch.candidates.includes(input.selectedImageUrl)) { + throw new Error('选中图片不属于当前动作首帧任务') + } + const run = batch.run + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + + try { + const animationTask = await generationApis.create({ + type: 'complete_animation', + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + actionType: run.actionType, + firstFrameUrl: input.selectedImageUrl, + prompt: run.prompt, + referenceMedia: [], + }) + const generating = startAnimationFromCandidate(run, animationTask.id, now()) + store.save(generating) + const terminal = await waitForTerminal(generationApis, animationTask) + requireAnimation(terminal) + const completed = completeGenerationStep( + requireRun(store, run.id), + 'complete-animation', + 'review', + now(), + ) + store.save(completed) + return completed + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '完整动画生成失败'), now()) + throw asError(cause) + } + } + + async function resumeAction(runId: string): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active' || requireStep(run, 'review').status === 'active') return run + const animationStep = requireStep(run, 'complete-animation') + if (animationStep.status !== 'active') return run + if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在,无法恢复') + + try { + const terminal = await waitForTerminal( + generationApis, + await generationApis.get(run.projectId, animationStep.taskId), + ) + requireAnimation(terminal) + run = completeGenerationStep(run, 'complete-animation', 'review', now()) + store.save(run) + return run + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '完整动画恢复失败'), now()) + throw asError(cause) + } + } + + async function approveAction(runId: string): Promise { + const run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active' || requireStep(run, 'review').status !== 'active') { + throw new Error('动作尚未进入可审核状态') + } + const animationStep = requireStep(run, 'complete-animation') + if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在') + const animation = requireAnimation( + await generationApis.get(run.projectId, animationStep.taskId), + ) + + const character = await characterApis.get(run.characterId) + const outfit = character.outfits.find((item) => item.id === run.outfitId) + if (!outfit) throw new Error('动作所属造型不存在') + const action: Action = { + id: run.actionId, + outfitId: outfit.id, + name: run.actionName, + kind: 'custom', + type: run.actionType, + fps: run.fps, + keyFrameIndex: null, + frames: animation.frames.map((frame) => ({ + imageUrl: frame.url, + durationMs: null, + rootMotion: null, + })), + } + const saved = await characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfit.id + ? { + ...item, + actions: [...item.actions.filter((existing) => existing.id !== run.actionId), action], + } + : item, + ), + }) + + const completedAt = now() + const completed = editCurrentRevision(run, completedAt, (revision) => { + requireRevisionStep(revision, 'review').status = 'passed' + requireRevisionStep(revision, 'export').status = 'passed' + revision.status = 'completed' + revision.exportStatus = 'exported' + }) as WorkflowRun + const result: WorkflowRun = { + ...completed, + status: 'completed', + updatedAt: completedAt, + } + store.save(result) + return { + run: result, + character: saved, + characterId: run.characterId, + outfitId: run.outfitId, + actionId: run.actionId, + } + } + + return { + startCharacter, + resumeCharacterCandidates, + confirmCharacter, + startAction, + resumeActionFirstFrameCandidates, + confirmActionFirstFrame, + resumeAction, + approveAction, + } +} + +function requireRun(store: WorkflowRunStore, runId: string): WorkflowRun { + const run = store.get(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') + return revision +} + +function requireRevisionStep(revision: WorkflowRevision, type: WorkflowStepType): WorkflowStep { + const step = revision.steps.find((item) => item.type === type) + if (!step) throw new Error(`WorkflowRun 缺少 ${type} 步骤`) + return step +} + +function requireStep(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + return requireRevisionStep(currentRevision(run), type) +} + +function editCurrentRevision( + run: WorkflowRun, + updatedAt: string, + edit: (revision: WorkflowRevision) => void, +): WorkflowRun { + const next = structuredClone(run) + edit(currentRevision(next)) + next.updatedAt = updatedAt + return next +} + +function advanceStep( + run: WorkflowRun, + currentType: WorkflowStepType, + nextType: WorkflowStepType, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const current = requireRevisionStep(revision, currentType) + const next = requireRevisionStep(revision, nextType) + if (current.status !== 'active' || next.status !== 'locked') { + throw new Error(`不能从 ${currentType} 推进到 ${nextType}`) + } + current.status = 'passed' + next.status = 'active' + }) +} + +function recordTask( + run: WorkflowRun, + type: WorkflowStepType, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const step = requireRevisionStep(revision, type) + if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) + step.taskId = taskId + step.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +/** + * 每次后端成功返回一个首帧任务 ID 就立即保存。 + * 如果第 3 个请求时页面刷新,恢复后只需补齐缺少的任务, + * 不会重复提交前两个。 + */ +function appendCandidateTask( + run: WorkflowRun, + type: WorkflowStepType, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const step = requireRevisionStep(revision, type) + if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) + if (step.candidateTaskIds.includes(taskId)) throw new Error('首帧候选任务 ID 重复') + if (step.candidateTaskIds.length >= ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + throw new Error('首帧候选任务数量已达上限') + } + step.candidateTaskIds.push(taskId) + step.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +/** 选中首帧后,把候选步骤和完整动画 taskId 一次写入同一份快照。 */ +function startAnimationFromCandidate( + run: WorkflowRun, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const candidate = requireRevisionStep(revision, 'first-frame-candidate') + const animation = requireRevisionStep(revision, 'complete-animation') + if (candidate.status !== 'active' || animation.status !== 'locked') { + throw new Error('当前 WorkflowRun 不能从首帧候选进入完整动画') + } + candidate.status = 'passed' + animation.status = 'active' + animation.taskId = taskId + animation.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +function completeGenerationStep( + run: WorkflowRun, + currentType: WorkflowStepType, + nextType: WorkflowStepType, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const current = requireRevisionStep(revision, currentType) + const next = requireRevisionStep(revision, nextType) + const hasGenerationTasks = + current.taskId !== null || + (current.type === 'first-frame' && + current.candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) + if (current.status !== 'active' || !hasGenerationTasks || next.status !== 'locked') { + throw new Error(`${currentType} 步骤没有可完成的生成任务`) + } + current.status = 'passed' + next.status = 'active' + revision.generationStatus = nextType === 'complete-animation' ? 'in_progress' : 'completed' + }) +} + +function failActiveRun( + store: WorkflowRunStore, + runId: string, + message: string, + updatedAt: string, +): void { + const existing = store.get(runId) + if (!existing || existing.status !== 'active') return + const failed = editCurrentRevision(existing, updatedAt, (revision) => { + const active = revision.steps.find((step) => step.status === 'active') + if (active) { + active.status = 'failed' + active.error = message + active.submissionId = null + } + revision.status = 'failed' + revision.generationStatus = 'failed' + }) + failed.status = 'failed' + store.save(failed) +} + +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 toCandidateBatch( + run: WorkflowRun, + generationId: string, + result: CharacterTemplateGenerationResult, +): CharacterCandidateBatch { + return { run, 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 settledBeforeSubscription = false + const settle = (event: GenerationEvent) => { + if (event.status !== 'completed' && event.status !== 'failed') return + settledBeforeSubscription = true + stop() + resolve({ + 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, settle) + if (settledBeforeSubscription) stop() + } catch (cause) { + reject(asError(cause)) + } + }) +} + +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..75ca223 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/index.ts @@ -0,0 +1,14 @@ +/** + * WorkflowRun 本地仓库的子目录入口。 + * + * 本目录回答“WorkflowRun 在当前前端怎样创建、校验、保存和通知”。 + * 它依赖 model,但 model 不反向依赖 Store。后续接入服务器持久化时, + * 可替换这层的适配实现,不需改变 WorkflowRun 领域类型。 + */ + +export { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } 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..d1c53f5 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -0,0 +1,439 @@ +/** + * WorkflowRun Store 的可执行业务规则。 + * + * 这些测试不是在验证页面点击,而是锁定数据层不得破坏的契约: + * 角色/动作任务的步骤必须分开,完成角色任务前必须有正式资产, + * 临时候选不得进入持久化快照,Revision 历史引用不得悬空。 + */ + +import { describe, expect, it, vi } from 'vitest' + +import type { WorkflowRevision, WorkflowRun, WorkflowRunPurpose, WorkflowStep } from '../model' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +import { CHARACTER_CANDIDATE_COUNT, WORKFLOW_STEP_ORDERS } from '../model/constants' + +/** 最小 localStorage 替身:既可观察序列化结果,也可主动模拟浏览器存储失败。 */ +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem() { + return this.value + } + + setItem(_key: string, value: string) { + if (this.failOnSet) throw new Error('storage full') + this.value = value + } +} + +/** 根据 purpose 生成测试快照,避免测试自己重复写一套容易过期的步骤顺序。 */ +function createSteps( + prefix: string, + purpose: WorkflowRunPurpose = 'create_character', + activeIndex = 0, +): WorkflowStep[] { + return WORKFLOW_STEP_ORDERS[purpose].map((type, index) => ({ + id: `${prefix}:${type}`, + type, + status: index === activeIndex ? 'active' : 'locked', + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })) +} + +function createRevision( + id = 'revision-1', + purpose: WorkflowRunPurpose = 'create_character', +): WorkflowRevision { + return { + id, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(id, purpose), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-08-03T00:00:00.000Z', + } +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + selectedAt: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [createRevision()], + prompt: 'Create a hero', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + } +} + +function createAddActionRun(): WorkflowRun { + const base = createRun('run-add-action') + return { + id: base.id, + projectId: base.projectId, + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + driver: base.driver, + status: base.status, + currentRevisionId: base.currentRevisionId, + revisions: [createRevision('revision-1', 'add_action')], + prompt: 'Walk forward', + createdAt: base.createdAt, + updatedAt: base.updatedAt, + } +} + +/** 构造“从已通过步骤重做”的两版本历史,用于校验来源链。 */ +function createRunWithHistory(): WorkflowRun { + const first = createRevision() + first.status = 'abandoned' + first.steps = first.steps.map((step, index) => ({ + ...step, + status: index === 0 ? 'passed' : 'locked', + })) + const second = createRevision('revision-2') + second.basedOnRevisionId = first.id + second.restartStepId = first.steps[0]!.id + second.steps[0]!.referenceStepIds = [first.steps[0]!.id] + + return { + ...createRun(), + currentRevisionId: second.id, + revisions: [first, second], + } +} + +describe('createWorkflowRunStore', () => { + // 创建契约:同一界面可连续完成两任务,但底层必须创建两种不同步骤模板的 Run。 + it('creates a character task with only the character steps', () => { + const ids = ['run-1', 'revision-1', 'step-1', 'step-2', 'step-3'] + const store = createWorkflowRunStore({ + storage: null, + createId: () => ids.shift()!, + now: () => '2026-08-03T01:00:00.000Z', + }) + + const run = store.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' Create a hero ', + }) + + expect(CHARACTER_CANDIDATE_COUNT).toBe(4) + expect(run).toMatchObject({ + id: 'run-1', + purpose: 'create_character', + characterId: null, + outfitId: null, + selectedAt: null, + prompt: 'Create a hero', + createdAt: '2026-08-03T01:00:00.000Z', + updatedAt: '2026-08-03T01:00:00.000Z', + }) + expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( + WORKFLOW_STEP_ORDERS.create_character, + ) + expect(run.revisions[0]?.steps.map((step) => step.status)).toEqual([ + 'active', + 'locked', + 'locked', + ]) + }) + + it('creates an action task only from an existing character and outfit', () => { + const ids = [ + 'run-2', + 'revision-2', + 'step-1', + 'step-2', + 'step-3', + 'step-4', + 'step-5', + 'step-6', + 'action-1', + ] + const store = createWorkflowRunStore({ + storage: null, + createId: () => ids.shift()!, + now: () => '2026-08-03T02:00:00.000Z', + }) + + const run = store.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'manual', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + prompt: 'Walk forward', + }) + + expect(run).toMatchObject({ + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + }) + expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( + WORKFLOW_STEP_ORDERS.add_action, + ) + }) + + // 快照所有权契约:保存后修改原对象或查询结果,都不能绕过 Store 改写内存。 + it('persists versioned snapshots and returns defensive copies', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const run = createRun() + + store.save(run) + run.prompt = 'changed outside' + const restored = store.get(run.id)! + restored.revisions[0]!.steps[0]!.status = 'failed' + + expect(store.get(run.id)?.prompt).toBe('Create a hero') + expect(store.get(run.id)?.revisions[0]?.steps[0]?.status).toBe('active') + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates a valid revision history and exposes it through list', () => { + const run = createRunWithHistory() + const storage = new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), + ) + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + expect(store.list()).toEqual([run]) + }) + + // 恢复边界采用严格白名单:坏 JSON、未知版本和断裂历史链都不得进入内存。 + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + [ + 'missing history source', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { ...createRunWithHistory().revisions[1], basedOnRevisionId: 'missing' }, + ], + }, + ], + }), + ], + [ + 'unknown referenced step', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { + ...createRunWithHistory().revisions[1], + steps: createRunWithHistory().revisions[1]!.steps.map((step, index) => + index === 0 ? { ...step, referenceStepIds: ['missing-step'] } : step, + ), + }, + ], + }, + ], + }), + ], + ])('ignores %s during hydration', (_label, serialized) => { + expect(createWorkflowRunStore({ storage: new TestStorage(serialized) }).list()).toEqual([]) + }) + + it('rejects invalid snapshots before they reach memory', () => { + const store = createWorkflowRunStore({ storage: null }) + const invalid = createRun() + invalid.revisions[0]!.steps[0]!.error = 'failed without failed status' + + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + expect(store.get(invalid.id)).toBeNull() + }) + + // 动作不是游离资产;它必须同时定位角色和具体造型。 + it('requires character and outfit references when adding an action', () => { + const valid = createAddActionRun() + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const invalid = { + ...valid, + characterId: null, + outfitId: null, + } as unknown as WorkflowRun + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + + const hydrated = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), + ), + }) + expect(hydrated.get(invalid.id)).toBeNull() + }) + + // 用户点选候选并不等于任务完成;必须等正式资产保存成功后再原子性填入三个字段。 + it('requires a saved asset and selection time before completing character creation', () => { + const valid = createRun() + valid.status = 'completed' + valid.characterId = 'character-1' + valid.outfitId = 'outfit-1' + valid.selectedAt = '2026-08-03T03:00:00.000Z' + valid.revisions[0]!.status = 'completed' + valid.revisions[0]!.steps = valid.revisions[0]!.steps.map((step) => ({ + ...step, + status: 'passed', + })) + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const missingSelection = { ...valid, selectedAt: null } as unknown as WorkflowRun + expect(() => store.save(missingSelection)).toThrow('Invalid WorkflowRun snapshot') + }) + + // taskId 是追踪线索,不是仅在加载中存活的 UI 状态。 + it('retains a generation task id after its step passes', () => { + const run = createRun() + run.revisions[0]!.steps[0] = { + ...run.revisions[0]!.steps[0]!, + status: 'passed', + taskId: 'generation-1', + } + run.revisions[0]!.steps[1] = { + ...run.revisions[0]!.steps[1]!, + status: 'active', + } + const store = createWorkflowRunStore({ storage: null }) + + store.save(run) + + expect(store.get(run.id)?.revisions[0]?.steps[0]?.taskId).toBe('generation-1') + }) + + it('requires exactly four task ids before the first-frame batch can pass', () => { + const run = createAddActionRun() + const steps = run.revisions[0]!.steps + steps[0]!.status = 'passed' + steps[1]!.status = 'passed' + steps[1]!.candidateTaskIds = ['first-1', 'first-2', 'first-3'] + steps[2]!.status = 'active' + const store = createWorkflowRunStore({ storage: null }) + + expect(() => store.save(run)).toThrow('Invalid WorkflowRun snapshot') + + steps[1]!.candidateTaskIds.push('first-4') + store.save(run) + expect(store.get(run.id)?.revisions[0]?.steps[1]?.candidateTaskIds).toHaveLength(4) + }) + + // 四张候选属于临时缓存;运行历史只记录生成 taskId 和最终正式资产引用。 + it('rejects temporary candidate payloads in persisted workflow steps', () => { + const run = createRun() + const withCandidates = { + ...run, + revisions: [ + { + ...run.revisions[0], + steps: run.revisions[0]!.steps.map((step, index) => + index === 1 + ? { + ...step, + output: { + candidates: ['temporary-1', 'temporary-2', 'temporary-3', 'temporary-4'], + }, + } + : step, + ), + }, + ], + } as unknown as WorkflowRun + const store = createWorkflowRunStore({ storage: null }) + + expect(() => store.save(withCandidates)).toThrow('Invalid WorkflowRun snapshot') + }) + + // localStorage 失败不应让当前会话已完成的操作倒退,但刷新恢复能力会降级。 + it('keeps memory authoritative when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + + expect(() => store.save(createRun())).not.toThrow() + expect(store.get('run-1')).toEqual(createRun()) + }) + + it('notifies run and history subscribers without sharing mutable values', () => { + const store = createWorkflowRunStore({ storage: null }) + const runListener = vi.fn((run: WorkflowRun) => { + run.prompt = 'listener mutation' + }) + const listListener = vi.fn() + const unsubscribeRun = store.subscribe('run-1', runListener) + const unsubscribeAll = store.subscribeAll(listListener) + + store.save(createRun()) + + expect(store.get('run-1')?.prompt).toBe('Create a hero') + expect(listListener).toHaveBeenCalledWith([createRun()]) + unsubscribeRun() + unsubscribeAll() + store.save({ ...createRun(), prompt: 'second save' }) + expect(runListener).toHaveBeenCalledTimes(1) + expect(listListener).toHaveBeenCalledTimes(1) + }) + + it('uses the stable browser storage key', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ storage: { getItem: () => null, setItem } }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) 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..438be22 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -0,0 +1,465 @@ +/** + * WorkflowRun 的本地仓库与运行时边界校验。 + * + * 这个 Store 只做四件事:创建合法初始快照、保存/读取快照、刷新恢复、通知订阅者。 + * 它不是 WorkflowController:不调 Generation API、不处理 SSE、不决定何时进入下一步, + * 也不负责调用后端候选图清理接口。这些编排行为由同一 Entity 下的 + * WorkflowRun Service 组合已有 Generation/Character 端口完成。 + * + * localStorage 是当前没有 WorkflowRun 后端持久化时的刷新恢复适配器, + * 不代表把浏览器宣布为最终服务器数据源。 + */ + +import type { + CreateWorkflowRunInput, + WorkflowRevision, + WorkflowRun, + WorkflowRunPurpose, +} from '../model' +import { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_STATUSES, +} from '../model/constants' + +/** 稳定 key 保证刷新前后读取同一份数据,不随页面路由或组件名改动。 */ +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' + +/** + * 持久化数据版本。当快照结构或业务不变式变更时递增, + * 防止新代码将旧 JSON 误认为合法运行状态。 + */ +export const WORKFLOW_RUN_STORAGE_VERSION = 3 + +type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void + +/** 只依赖最小存储能力,测试可用内存替身,未来也可换成其他适配器。 */ +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** + * WorkflowRun 的最小仓库接口。 + * + * create 只产生初始合法快照;save 保存已由业务层推进的整体快照。 + * subscribe 服务单个创作页,subscribeAll 服务历史列表;两者都不改写数据。 + */ +export interface WorkflowRunStore { + create(input: CreateWorkflowRunInput): WorkflowRun + get(runId: WorkflowRun['id']): WorkflowRun | null + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** null 表示仅保存在当前内存;未传时浏览器默认使用 localStorage。 */ + storage?: WorkflowRunStorage | null + /** 测试可注入确定性 ID;生产默认使用 crypto.randomUUID。 */ + createId?: () => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +/** + * 校验单个步骤的关键不变式。 + * + * - failed 必须有可读错误,非 failed 不得残留旧错误; + * - submissionId 只能出现在 active 且与 taskId 互斥; + * - taskId 可在 passed/failed 后保留,便于追踪后端任务; + * - 只有 first-frame 可保存最多 4 个 candidateTaskIds,passed 时必须已集齐 4 个; + * - 拒绝 input/output 是为了防止四张临时候选或页面对象被塞进长期快照。 + */ +function isWorkflowStep(value: unknown, expectedType: string): boolean { + if (!isRecord(value)) return false + const candidateTaskIds = isStringArray(value.candidateTaskIds) ? value.candidateTaskIds : null + + const errorIsValid = + isNullableString(value.error) && + (value.status === 'failed' + ? typeof value.error === 'string' && value.error.trim().length > 0 + : value.error === null) + const taskStateIsValid = + isNullableString(value.taskId) && + candidateTaskIds !== null && + new Set(candidateTaskIds).size === candidateTaskIds.length && + candidateTaskIds.every((id) => id.length > 0) && + isNullableString(value.submissionId) && + !(value.taskId !== null && value.submissionId !== null) && + (value.submissionId === null || value.status === 'active') && + (value.taskId === null || ['active', 'passed', 'failed'].includes(String(value.status))) + const candidateTasksAreValid = + candidateTaskIds !== null && + (expectedType === 'first-frame' + ? value.taskId === null && + candidateTaskIds.length <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && + (value.status !== 'passed' || + candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) + : candidateTaskIds.length === 0) + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + value.type === expectedType && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + !('input' in value) && + !('output' in value) && + taskStateIsValid && + candidateTasksAreValid && + errorIsValid && + isStringArray(value.referenceStepIds) + ) +} + +/** + * Revision 必须完整包含当前 purpose 的步骤模板,数量、顺序和 type 都要一致。 + * 这会阻止 add_action 在恢复时被误塞入角色母版步骤,也阻止页面自行改变顺序。 + */ +function isWorkflowRevision( + value: unknown, + purpose: WorkflowRunPurpose, +): value is WorkflowRevision { + if (!isRecord(value) || !Array.isArray(value.steps)) return false + const stepIds = value.steps.map((step) => (isRecord(step) ? step.id : null)) + const expectedOrder = WORKFLOW_STEP_ORDERS[purpose] + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + value.steps.length === expectedOrder.length && + value.steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) && + new Set(stepIds).size === stepIds.length && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +/** + * 验证 Revision 历史链,防止伪造或悬空引用。 + * + * 首版不能有来源;后续版本必须指向更早的 Revision,且只能从该版本中 + * 已 passed 的步骤重开。referenceStepIds 只能引用已经出现的旧步骤, + * 不能指向未来版本或不存在的 ID。 + */ +function hasValidRevisionLine(revisions: WorkflowRevision[]): boolean { + const prior = new Map() + const priorStepIds = new Set() + + for (const [index, revision] of revisions.entries()) { + if (prior.has(revision.id)) return false + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if (revision.basedOnRevisionId === null || revision.restartStepId === null) return false + const source = prior.get(revision.basedOnRevisionId) + if ( + !source?.steps.some( + (step) => step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + if ( + revision.steps.some((step) => + step.referenceStepIds.some((stepId) => !priorStepIds.has(stepId)), + ) + ) { + return false + } + prior.set(revision.id, revision) + revision.steps.forEach((step) => priorStepIds.add(step.id)) + } + + return true +} + +/** + * 整体 Run 校验。它在两个不可信边界调用:读取 localStorage 和 save() 写入前。 + * 因此 TypeScript 类型正确仍不够;JSON、旧版数据和手工断言都可能绕过编译期。 + */ +function isWorkflowRun(value: unknown): value is WorkflowRun { + if ( + !isRecord(value) || + !isMember(value.purpose, WORKFLOW_PURPOSES) || + !Array.isArray(value.revisions) || + value.revisions.length === 0 + ) { + return false + } + const purpose = value.purpose + if (!value.revisions.every((revision) => isWorkflowRevision(revision, purpose))) return false + + const revisions = value.revisions + const current = revisions.at(-1) + if (!current || current.id !== value.currentRevisionId || !hasValidRevisionLine(revisions)) { + return false + } + + // Run 的结果必须与当前 Revision 结果同步,避免页面各读一层时得到矛盾答案。 + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (current.status !== expectedRevisionStatus) return false + if (revisions.slice(0, -1).some((revision) => revision.status === 'active')) return false + + // 运行中/已中断保留唯一当前步骤;终态不得继续挂着 active 步骤。 + const activeStepCount = current.steps.filter((step) => step.status === 'active').length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + // + // 角色 Run 有两个合法阶段:尚未选择时三个字段都为 null, + // 或正式保存成功后 ID 与选择时间同时存在。动作 Run 则从创建起就必须绑定角色造型。 + const targetIsValid = + value.purpose === 'add_action' + ? isNonEmptyString(value.characterId) && + isNonEmptyString(value.outfitId) && + value.selectedAt === undefined && + isNonEmptyString(value.actionId) && + isNonEmptyString(value.actionName) && + ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType)) && + typeof value.fps === 'number' && + Number.isFinite(value.fps) && + value.fps > 0 + : value.purpose === 'create_character' && + ((value.characterId === null && value.outfitId === null && value.selectedAt === null) || + (isNonEmptyString(value.characterId) && + isNonEmptyString(value.outfitId) && + isNonEmptyString(value.selectedAt))) + + if ( + value.purpose === 'create_character' && + value.status === 'completed' && + value.characterId === null + ) { + return false + } + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNonEmptyString(value.projectId) && + targetIsValid && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + isNullableString(value.prompt) && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.updatedAt) + ) +} + +/** + * 持久化读取采用“失败即忽略”策略:一条损坏数据不能阻止应用启动。 + * 这不是默默修复错误;无法证明合法的 Run 不进入内存,避免错误状态被继续推进。 + */ +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + const value: unknown = JSON.parse(serialized) + if ( + !isRecord(value) || + value.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(value.runs) + ) { + return [] + } + return value.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } catch { + return [] + } +} + +/** SSR/测试环境没有 window,隐私模式也可能拒绝 localStorage,因此存储能力必须可降级。 */ +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage + } catch { + return null + } +} + +/** 运行、版本和步骤都要跨刷新稳定引用,所以不使用数组下标或时间戳充当 ID。 */ +function createRandomId(): string { + if (typeof globalThis.crypto?.randomUUID !== 'function') { + throw new Error('crypto.randomUUID is required to create a WorkflowRun') + } + return globalThis.crypto.randomUUID() +} + +/** + * 创建 WorkflowRun Store。 + * + * 内存快照是当前会话的权威状态,localStorage 仅用于刷新恢复。 + * 所以存储写入失败时不回滚内存:用户当前页面仍可继续工作, + * 但刷新恢复能力已降级。未来接入后端持久化时,应替换适配器而不改变业务模型。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const createId = options.createId ?? createRandomId + const now = options.now ?? (() => new Date().toISOString()) + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + const listListeners = new Set() + + const snapshotList = () => [...runs.values()].map((run) => structuredClone(run)) + + const store: WorkflowRunStore = { + create(input) { + // create 只在用户真正发起任务时调用: + // 选好角色后进入动作区域不创建空 Run,点击“生成动作”才创建 add_action。 + const createdAt = now() + const runId = createId() + const revisionId = createId() + // 初始时只激活第一步,后续步骤等待前置条件通过。 + const steps = WORKFLOW_STEP_ORDERS[input.purpose].map((type, index) => ({ + id: createId(), + type, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })) + const base = { + id: runId, + projectId: input.projectId, + purpose: input.purpose, + driver: input.driver, + status: 'active' as const, + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active' as const, + steps, + generationStatus: 'not_started' as const, + exportStatus: 'not_exported' as const, + createdAt, + }, + ], + prompt: input.prompt?.trim() || null, + createdAt, + updatedAt: createdAt, + } + const run: WorkflowRun = + input.purpose === 'create_character' + ? { ...base, purpose: input.purpose, characterId: null, outfitId: null, selectedAt: null } + : { + ...base, + purpose: input.purpose, + characterId: input.characterId, + outfitId: input.outfitId, + actionId: createId(), + actionName: input.actionName.trim(), + actionType: input.actionType, + fps: input.fps, + } + + // 统一走 save 以复用运行时校验、持久化和订阅通知,避免 create 产生特例状态。 + store.save(run) + return structuredClone(run) + }, + get(runId) { + const run = runs.get(runId) + return run ? structuredClone(run) : null + }, + list: snapshotList, + save(run) { + if (!isWorkflowRun(run)) throw new TypeError('Invalid WorkflowRun snapshot') + // 内外都使用深拷贝,防止调用方在 save/get 后继续修改对象,绕过校验篡改 Store。 + const saved = structuredClone(run) + runs.set(saved.id, saved) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 持久化失败不撤销已经写入的当前会话状态,只降级刷新恢复能力。 + } + + for (const listener of listeners.get(saved.id) ?? []) { + try { + // 每个订阅者获得独立副本,一个页面不能通过修改参数影响另一个页面。 + listener(structuredClone(saved)) + } catch { + // 一个订阅方失败不能阻断其他订阅方。 + } + } + for (const listener of listListeners) { + try { + listener(snapshotList()) + } catch { + // 历史列表订阅方失败不影响已保存状态。 + } + } + }, + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, + } + + return store +}