diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d4..16b47030 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -4,26 +4,17 @@ import type { MediaReference } from '../media' /** * Generation 是业务数据,不是「调用图片生成能力」。 * 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。 - * - * 后端只有 GenerationTask 一个实体,generation 与 task 指同一条记录; - * `/generation/tasks/{task_id}` 里的 tasks 只是路径段,前端不为它另立实体。 - */ - -/** - * 后端 GenerationTask.status,与 WorkflowRevision.generationStatus 不是一回事: - * 这里是单次生成任务的状态,那里是一个版本在生成阶段的汇总状态。 - * pending 表示已提交但尚未执行。 */ -export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' -/** - * 生成对应的三个前端可见异步步骤。 - * 它是前端工作流粒度,不等于后端 task_type——后端只有 character_image 与 - * character_action 两种,character_template 和 first_frame 都落在 character_image 上。 - * 完整动画内部可含视频生成、截帧和多次图像处理,但对前端仍是一次 Generation。 - */ +/** 生成对应的三个前端可见异步步骤。 */ export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' +/** 完整动作默认生成帧数;首帧生成仍固定为 1 帧。 */ +export const COMPLETE_ANIMATION_FRAME_COUNT = 32 + +/** 后端单次生成任务的生命周期。 */ +export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed' + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ @@ -35,6 +26,10 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */ + spriteWidth: number + /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */ + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ @@ -74,15 +69,84 @@ export interface CharacterTemplateGenerationResult { images: readonly GeneratedImage[] } +/** + * Generation.result 来自运行时边界,写回 WorkflowRun 前必须按生成类型收窄。 + * + * 兼容后端两种返回格式: + * - 旧版单图:`{ type, image_url: "..." }` + * - 新版多图:`{ type, image_urls: ["...", "..."] }` + */ +export function parseCharacterTemplateGenerationResult( + value: unknown, +): CharacterTemplateGenerationResult | null { + if ( + !isRecord(value) || + (value.type !== 'character_template' && value.type !== 'character_image') + ) { + return null + } + + // 优先使用 image_urls(多图),兼容 image_url(单图) + const rawUrls: string[] = [] + if (Array.isArray(value.image_urls)) { + for (const item of value.image_urls) { + if (typeof item === 'string' && item.length > 0) rawUrls.push(item) + } + } else if (typeof value.image_url === 'string' && value.image_url.length > 0) { + rawUrls.push(value.image_url) + } + + // 兼容旧版 images 数组格式 + if (rawUrls.length === 0 && Array.isArray(value.images)) { + for (const image of value.images) { + if (isRecord(image) && typeof image.url === 'string' && image.url.length > 0) { + rawUrls.push(image.url) + } + } + } + + if (rawUrls.length === 0) return null + + const images: GeneratedImage[] = rawUrls.map((url) => ({ url })) + return { type: 'character_template', images } +} + export interface FirstFrameGenerationResult { type: 'first_frame' image: GeneratedImage } +export interface GeneratedAnimationFrame extends GeneratedImage { + durationMs: number | null +} + /** 帧顺序由数组位置表达。 */ export interface CompleteAnimationGenerationResult { type: 'complete_animation' - frames: readonly GeneratedImage[] + actionType: ActionType + frames: readonly GeneratedAnimationFrame[] +} + +/** 校验已经过适配层归一化的完整动画结果,供本地持久化恢复使用。 */ +export function parseCompleteAnimationGenerationResult( + value: unknown, +): CompleteAnimationGenerationResult | null { + if (!isRecord(value) || value.type !== 'complete_animation') return null + if (!['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType))) return null + if (!Array.isArray(value.frames) || value.frames.length === 0) return null + const frames: GeneratedAnimationFrame[] = [] + for (const frame of value.frames) { + if ( + !isRecord(frame) || + typeof frame.url !== 'string' || + frame.url.length === 0 || + (frame.durationMs !== null && typeof frame.durationMs !== 'number') + ) { + return null + } + frames.push({ url: frame.url, durationMs: frame.durationMs as number | null }) + } + return { type: 'complete_animation', actionType: value.actionType as ActionType, frames } } export type GenerationResult = @@ -98,50 +162,49 @@ export type GenerationResultFor = : CompleteAnimationGenerationResult /** - * 一次生成任务的完整快照,创建、查询和断线恢复都用它。 + * 一次生成任务的完整快照。 * 它是服务端的资源,不是一次「调用能力」——前端创建它,然后订阅或轮询它的状态。 - * - * TType 在调用边界已知时保留精确类型;按 ID 恢复时用默认值,等运行时解析后再收窄。 - * 完成不代表工作流节点已通过,节点状态由 WorkflowStep 自己判定。 */ export interface Generation { + /** 创建接口返回的后端任务 ID。 */ id: string projectId: string /** 与创建时的输入判别字段保持同一字面量类型。 */ type: TType - status: TaskStatus + status: GenerationTaskStatus /** 完成前为 null;完成后形状由 type 决定。 */ result: GenerationResult | null /** status 为 failed 时有值。 */ error: string | null } -/** - * 一条状态变更事件。 - * 不含 projectId:后端事件 payload 只有 task_id、task_type、status, - * 以及完成时的 result 和失败时的 error_message。 - */ +/** 后端任务状态变化映射成同一份 Generation 快照。 */ export interface GenerationEvent extends Omit< Generation, 'id' | 'projectId' > { - /** 对应 Generation.id,字段名沿用后端事件里的 task_id。 */ + /** 字段名对应后端事件中的 task_id,但语义上仍是 Generation.id。 */ taskId: Generation['id'] } -/** Generation 对应的一组后端接口。服务端没有取消能力,因此这里不声明 cancel。 */ +/** Generation 对应的一组后端接口。 */ export interface GenerationApis { /** 创建一次生成任务。 */ create(input: T): Promise> + /** 按所属项目和任务 ID 读取生成任务的最新快照。 */ + get(projectId: Generation['projectId'], id: Generation['id']): Promise /** - * 按所属项目和任务 ID 读取最新快照。 - * projectId 不能从 id 推导,后端查询接口要求两者同时传入。 + * 订阅任务状态。当前后端没有 SSE 时,实现可以封装轮询;调用方不感知传输方式。 + * 返回取消订阅函数。 */ - get(projectId: Generation['projectId'], id: Generation['id']): Promise - /** 订阅状态变化,返回取消订阅函数。 */ subscribe( projectId: Generation['projectId'], id: Generation['id'], onEvent: (event: GenerationEvent) => void, + onError?: (error: Error) => void, ): () => void } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 79a3d212..ce2e85ae 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -27,7 +27,11 @@ export { characterApis } from './character' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export { + COMPLETE_ANIMATION_FRAME_COUNT, + parseCharacterTemplateGenerationResult, +} from './generation' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -35,6 +39,7 @@ export type { CompleteAnimationGenerationResult, FirstFrameGenerationInput, FirstFrameGenerationResult, + GeneratedAnimationFrame, GeneratedImage, Generation, GenerationApis, @@ -43,16 +48,21 @@ export type { GenerationResult, GenerationResultFor, GenerationType, - TaskStatus, + GenerationTaskStatus, } from './generation' /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { + ActionGenerationWorkflowStep, + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, CreateWorkflowRunInput, + CreateWorkflowRunStoreOptions, ExportStatus, GenerationStatus, WorkflowDriver, @@ -62,6 +72,7 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md new file mode 100644 index 00000000..cb3809ad --- /dev/null +++ b/frontend/src/entities/workflow-run/README.md @@ -0,0 +1,11 @@ +# WorkflowRun + +本目录保存 Controller 使用的 WorkflowRun 数据模型和本地存储。这里沿用当前工作区的最小概念: +`WorkflowRun + WorkflowRunStore`,不增加 Snapshot 或 Handle。 + +- `WorkflowRun` 是一条角色制作流程,持有 Revision、Step、角色关联和当前状态。 +- `WorkflowRevision` 只表达从历史步骤重新执行形成的新版本。 +- `WorkflowRunStore` 直接读写 WorkflowRun,并保证同一 Character 只绑定一条 Run。 +- 新增动作在原 Run 当前 Revision 末尾追加动作生成和审核步骤。 + +后端 GenerationTask 是步骤引用的异步任务,不等于 WorkflowRun。 diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 00000000..f01d61ba --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,16 @@ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 新建角色时的基础五步;完成后可继续追加 action-generation / review 成对步骤。 */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-generation', + 'review', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b5..9eae037e 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,26 +1,34 @@ -import type { Generation } from '../generation' +import type { + Generation, + CharacterTemplateGenerationInput, + CharacterTemplateGenerationResult, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, +} from '../generation' +import type { MediaReference } from '../media' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { WORKFLOW_STEP_ORDER } from './constants' /** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] /** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] /** * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.steps 的数组位置表达。 */ -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] @@ -28,63 +36,104 @@ export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] * 步骤的可用性和执行结果;不直接复用后端任务状态。 * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] /** * 单个版本的生命周期。 * abandoned 表示停止沿用但仍保留为历史。 */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] /** * 整次流程的汇总状态。 * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 + * 后端 Generation 是否真正停止是独立问题;前端中断只停止自动推进与订阅。 */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] -/** - * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 - * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。 - */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' +/** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] /** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' +export type ExportStatus = (typeof EXPORT_STATUSES)[number] -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ -export interface WorkflowStep { +interface WorkflowStepBase { /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ id: string - type: WorkflowStepType status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ - input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ - output: unknown /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 + * 任务本身不认识步骤,反向关联不存在。 */ taskId: Generation['id'] | null + /** + * 前端开始提交、但后端 taskId 尚未返回时的本地尝试标识。 + * 它非 null 而 taskId 为 null 时不能重复提交;若页面在这个窗口刷新, + * Controller 会把本地 Run 标为失败。它不是后端字段,也不冒充幂等键。 + */ + submissionId: string | null + /** 步骤失败后供页面解释原因;未失败时必须为 null。 */ + error: string | null /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ referenceStepIds: string[] } +/** 角色资料步骤保存的输入;参考媒体为空表示仅使用文字描述。 */ +export interface CharacterSetupStepInput { + description: string + referenceMedia: readonly MediaReference[] +} + +export interface CharacterSetupWorkflowStep extends WorkflowStepBase { + type: 'character-setup' + input: CharacterSetupStepInput | null + output: null +} + +export interface CharacterTemplateWorkflowStep extends WorkflowStepBase { + type: 'character-template' + /** 发起任务前为 null;提交时保存实际发送给 GenerationApis 的输入快照。 */ + input: CharacterTemplateGenerationInput | null + output: CharacterTemplateGenerationResult | null +} + +export interface ActionGenerationWorkflowStep extends WorkflowStepBase { + type: 'action-generation' + input: CompleteAnimationGenerationInput | null + /** Controller 验收通过的完整动画;实体只记录结果,不负责补帧或修复缺帧。 */ + output: CompleteAnimationGenerationResult | null +} + +type RemainingWorkflowStepType = Exclude< + WorkflowStepType, + 'character-setup' | 'character-template' | 'action-generation' +> + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选确认与审核的具体输入输出在对应纵切中继续收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;后续三步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | ActionGenerationWorkflowStep + | RemainingWorkflowStep + /** * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 + * 从历史步骤重开时,旧 Revision 保留为只读记录,新 Revision 引用它的重开步骤。 + * 新执行线中的下游步骤会清空并重新锁定,不能作为新生成的参考依据。 */ export interface WorkflowRevision { id: string @@ -94,8 +143,9 @@ export interface WorkflowRevision { restartStepId: string | null status: WorkflowRevisionStatus /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + * 当前版本先保存三个角色步骤,再保存一组或多组动作生成/审核步骤; + * 数组位置是步骤顺序及动作与审核配对关系的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 */ steps: WorkflowStep[] generationStatus: GenerationStatus @@ -115,6 +165,7 @@ export interface WorkflowRun { characterId: string | null /** 已有角色加动作时的目标造型;新建角色时为 null。 */ outfitId: string | null + /** 建立这条 Run 时的根意图;后续追加动作不会把 create_character 改写为 add_action。 */ purpose: WorkflowRunPurpose driver: WorkflowDriver status: WorkflowRunStatus @@ -155,3 +206,6 @@ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & baseFrameUrls: readonly string[] } ) + +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts new file mode 100644 index 00000000..aba30f1f --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,449 @@ +import { describe, expect, it, vi } from 'vitest' + +import { WORKFLOW_STEP_ORDER } from './constants' +import type { WorkflowRun, WorkflowStep } from './index' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' + +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem(): string | null { + return this.value + } + + setItem(_key: string, value: string): void { + if (this.failOnSet) throw new Error('storage unavailable') + this.value = value + } +} + +function createSteps(): WorkflowStep[] { + return WORKFLOW_STEP_ORDER.map((type, index) => { + const common = { + id: `revision-1:${type}`, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + if (type === 'character-setup') { + return { + ...common, + type, + input: { description: 'slime', referenceMedia: [] }, + output: null, + } + } + if (type === 'character-template') { + return { ...common, type, input: null, output: null } + } + return { ...common, type, input: null, output: null } as WorkflowStep + }) +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-30T12:00:00.000Z', + }, + ], + prompt: 'Create a slime', + } +} + +function createLegacyRun(): unknown { + const run = createRun() + const revision = run.revisions[0] + if (!revision) throw new Error('Expected a revision') + + const [characterSetup, characterTemplate, templateCandidate, actionGeneration, review] = + revision.steps + if (!characterSetup || !characterTemplate || !templateCandidate || !actionGeneration || !review) { + throw new Error('Expected the five current workflow steps') + } + + return { + ...run, + revisions: [ + { + ...revision, + steps: [ + characterSetup, + characterTemplate, + templateCandidate, + { ...actionGeneration, id: 'revision-1:action-setup', type: 'action-setup' }, + { ...actionGeneration, id: 'revision-1:first-frame', type: 'first-frame' }, + { ...actionGeneration, id: 'revision-1:complete-animation', type: 'complete-animation' }, + review, + { ...review, id: 'revision-1:export', type: 'export' }, + ], + }, + ], + } +} + +function createRestartedRun(): WorkflowRun { + const source = createRun() + const sourceRevision = source.revisions[0]! + const sourceSteps = sourceRevision.steps.map((step) => + step.type === 'character-setup' || step.type === 'character-template' + ? { ...step, status: 'passed' as const } + : step.type === 'template-candidate' + ? { ...step, status: 'active' as const } + : step, + ) + const restartedSteps = sourceSteps.map((step, index) => { + const common = { + ...step, + id: `revision-2:${step.type}`, + taskId: null, + submissionId: null, + error: null, + } + if (index === 0) return { ...common, status: 'passed' as const, referenceStepIds: [step.id] } + if (index === 1) { + return { + ...common, + status: 'active' as const, + output: null, + referenceStepIds: [step.id], + } + } + return { ...common, status: 'locked' as const, input: null, output: null, referenceStepIds: [] } + }) as WorkflowStep[] + + return { + ...source, + currentRevisionId: 'revision-2', + revisions: [ + { ...sourceRevision, status: 'abandoned', steps: sourceSteps }, + { + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + status: 'active', + steps: restartedSteps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-07-31T03:00:00.000Z', + }, + ], + } +} + +describe('createWorkflowRunStore', () => { + it('lists cloned snapshots and notifies whole-store subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const listener = vi.fn() + const unsubscribe = store.subscribeAll(listener) + + store.save(createRun('run-1')) + store.save(createRun('run-2')) + + expect(store.list().map((run) => run.id)).toEqual(['run-1', 'run-2']) + expect(listener).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: 'run-1' }), + expect.objectContaining({ id: 'run-2' }), + ]) + unsubscribe() + store.save(createRun('run-3')) + expect(listener).toHaveBeenCalledTimes(2) + }) + it('stores a versioned snapshot and returns defensive clones', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const source = createRun() + + store.save(source) + source.prompt = 'mutated outside' + + const firstRead = store.get(source.id) + expect(firstRead?.prompt).toBe('Create a slime') + + firstRead!.revisions[0].steps[0].status = 'failed' + expect(store.get(source.id)?.revisions[0].steps[0].status).toBe('active') + + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates valid runs from localStorage', () => { + const run = createRun() + const storage = new TestStorage( + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [run], + }), + ) + + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + }) + + it('hydrates a workflow with repeated action and review pairs', () => { + const run = createRun() + const revision = run.revisions[0]! + const originalAction = revision.steps.find((step) => step.type === 'action-generation')! + const originalReview = revision.steps.find((step) => step.type === 'review')! + revision.steps = [ + ...revision.steps.map((step) => ({ ...step, status: 'passed' as const })), + { + ...originalAction, + id: 'revision-1:action-generation:2', + status: 'active', + }, + { + ...originalReview, + id: 'revision-1:review:2', + status: 'locked', + }, + ] + const storage = new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), + ) + + expect(createWorkflowRunStore({ storage }).get(run.id)?.revisions[0]?.steps).toHaveLength(7) + }) + + it('finds the one workflow run bound to a character', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + run.characterId = 'character-1' + run.outfitId = 'outfit-1' + store.save(run) + + expect(store.getByCharacter('character-1')?.id).toBe(run.id) + expect(store.getByCharacter('missing')).toBeNull() + }) + + it('rejects a second workflow run bound to the same character', () => { + const store = createWorkflowRunStore({ storage: null }) + const first = { ...createRun(), characterId: 'character-1', outfitId: 'outfit-1' } + const duplicate = { ...createRun(), id: 'run-2', characterId: 'character-1' } + store.save(first) + + expect(() => store.save(duplicate)).toThrow('已经绑定到 WorkflowRun run-1') + expect(store.get('run-2')).toBeNull() + }) + + it('migrates version-three single-frame action output without dropping history', () => { + const run = createRun() + const revision = run.revisions[0]! + revision.steps = revision.steps.map((step) => { + if (step.type === 'character-setup') return { ...step, status: 'passed' } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed', + output: { type: 'character_template', images: [{ url: 'template.png' }] }, + } + } + if (step.type === 'template-candidate') return { ...step, status: 'passed' } + if (step.type === 'action-generation') { + return { + ...step, + status: 'passed', + input: { + type: 'complete_animation', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionType: 'idle', + firstFrameUrl: 'template.png', + prompt: null, + referenceMedia: ['template.png'], + }, + output: { type: 'first_frame', image: { url: 'frame.png' } }, + } as unknown as WorkflowStep + } + return { ...step, status: 'active' } + }) + const storage = new TestStorage(JSON.stringify({ version: 3, runs: [run] })) + + const restored = createWorkflowRunStore({ storage }).get(run.id) + const action = restored?.revisions[0]?.steps.find((step) => step.type === 'action-generation') + + expect(action?.output).toEqual({ + type: 'complete_animation', + actionType: 'idle', + frames: [{ url: 'frame.png', durationMs: null }], + }) + }) + + it('migrates a version-one run to the fixed five-step model', () => { + const store = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ + version: 1, + runs: [createLegacyRun()], + }), + ), + }) + + expect(store.get('run-1')?.revisions[0]?.steps.map((step) => step.type)).toEqual([ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-generation', + 'review', + ]) + expect(store.get('run-1')?.revisions[0]?.exportStatus).toBe('not_exported') + }) + + it('migrates version-two runs and restores their restart history', () => { + const legacyRun = createRun() + const versionTwoStore = createWorkflowRunStore({ + storage: new TestStorage(JSON.stringify({ version: 2, runs: [legacyRun] })), + }) + const historyStore = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [createRestartedRun()] }), + ), + }) + + expect(versionTwoStore.get('run-1')).toEqual(legacyRun) + expect(historyStore.get('run-1')).toMatchObject({ + currentRevisionId: 'revision-2', + revisions: [ + { id: 'revision-1', status: 'abandoned' }, + { + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + }, + ], + }) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + ['invalid payload', JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: {} })], + [ + 'invalid run', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), currentRevisionId: 'missing-revision' }], + }), + ], + [ + 'inconsistent run status', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ ...createRun(), status: 'failed' }], + }), + ], + [ + 'orphaned restart revision', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRestartedRun(), + revisions: [ + createRestartedRun().revisions[0], + { ...createRestartedRun().revisions[1], basedOnRevisionId: 'missing-revision' }, + ], + }, + ], + }), + ], + ])('ignores %s in localStorage', (_label, serialized) => { + const store = createWorkflowRunStore({ storage: new TestStorage(serialized) }) + + expect(store.get('run-1')).toBeNull() + }) + + it('keeps the memory snapshot and notifies subscribers when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + const listener = vi.fn() + const run = createRun() + + store.subscribe(run.id, listener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(listener).toHaveBeenCalledWith(run) + }) + + it('isolates subscriber values and stops notifications after unsubscribe', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + const unsubscribeFirst = store.subscribe(run.id, (savedRun) => { + savedRun.prompt = 'mutated by first listener' + }) + const unsubscribeSecond = store.subscribe(run.id, secondListener) + + store.save(run) + + expect(secondListener).toHaveBeenLastCalledWith(run) + expect(store.get(run.id)).toEqual(run) + + unsubscribeFirst() + unsubscribeSecond() + store.save({ ...run, prompt: 'new prompt' }) + + expect(secondListener).toHaveBeenCalledTimes(1) + }) + + it('does not let one failing subscriber block the saved state or other subscribers', () => { + const store = createWorkflowRunStore({ storage: null }) + const run = createRun() + const secondListener = vi.fn() + + store.subscribe(run.id, () => { + throw new Error('render failed') + }) + store.subscribe(run.id, secondListener) + + expect(() => store.save(run)).not.toThrow() + expect(store.get(run.id)).toEqual(run) + expect(secondListener).toHaveBeenCalledWith(run) + }) + + it('uses the stable storage key by default', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ + storage: { + getItem: vi.fn(() => null), + setItem, + }, + }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 00000000..de169d95 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,522 @@ +import type { WorkflowRun } from './index' +import { + parseCharacterTemplateGenerationResult, + parseCompleteAnimationGenerationResult, +} from '../generation' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDER, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 4 + +type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void + +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface WorkflowRunStore { + get(runId: WorkflowRun['id']): WorkflowRun | null + getByCharacter(characterId: string): WorkflowRun | null + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** + * 传 null 可显式创建仅内存存储;不传时在浏览器中使用 localStorage。 + * 该入口也让纯逻辑测试无需模拟完整 DOM。 + */ + storage?: WorkflowRunStorage | null +} + +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 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) +} + +function isWorkflowStep(value: unknown): boolean { + if (!isRecord(value)) return false + + const commonFieldsAreValid = + typeof value.id === 'string' && + isMember(value.type, WORKFLOW_STEP_ORDER) && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + isNullableString(value.taskId) && + isNullableString(value.submissionId) && + isStringArray(value.referenceStepIds) && + 'input' in value && + 'output' in value + if (!commonFieldsAreValid) return false + const error = value.error + if (!isNullableString(error)) return false + if ( + (value.status === 'failed' && (error === null || error.trim().length === 0)) || + (value.status !== 'failed' && error !== null) + ) { + return false + } + + if (value.type === 'character-setup') { + return ( + value.output === null && + (value.input === null || + (isRecord(value.input) && + typeof value.input.description === 'string' && + isStringArray(value.input.referenceMedia))) + ) + } + if (value.type === 'character-template') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'character_template' && + typeof value.input.projectId === 'string' && + typeof value.input.prompt === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCharacterTemplateGenerationResult(value.output) !== null) + ) + } + if (value.type === 'action-generation') { + return ( + (value.input === null || + (isRecord(value.input) && + value.input.type === 'complete_animation' && + typeof value.input.projectId === 'string' && + typeof value.input.characterId === 'string' && + typeof value.input.outfitId === 'string' && + typeof value.input.firstFrameUrl === 'string' && + typeof value.input.actionType === 'string' && + isStringArray(value.input.referenceMedia))) && + (value.output === null || parseCompleteAnimationGenerationResult(value.output) !== null) + ) + } + return true +} + +function isWorkflowRevision(value: unknown): boolean { + if (!isRecord(value)) return false + + return ( + typeof value.id === 'string' && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + Array.isArray(value.steps) && + hasValidStepSequence(value.steps) && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +/** 角色前三步只出现一次,后面只能按“动作生成 + 审核”成对追加。 */ +function hasValidStepSequence(steps: unknown[]): boolean { + if (steps.length < WORKFLOW_STEP_ORDER.length || (steps.length - 3) % 2 !== 0) return false + const ids = new Set() + for (const [index, step] of steps.entries()) { + if (!isWorkflowStep(step) || !isRecord(step) || typeof step.id !== 'string') return false + if (ids.has(step.id)) return false + ids.add(step.id) + const expectedType = + index < 3 + ? WORKFLOW_STEP_ORDER[index] + : (index - 3) % 2 === 0 + ? 'action-generation' + : 'review' + if (step.type !== expectedType) return false + } + return true +} + +function isWorkflowRun(value: unknown): value is WorkflowRun { + if (!isRecord(value) || !Array.isArray(value.revisions)) return false + + const fieldsAreValid = + typeof value.id === 'string' && + typeof value.projectId === 'string' && + isNullableString(value.characterId) && + isNullableString(value.outfitId) && + isMember(value.purpose, WORKFLOW_PURPOSES) && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + typeof value.currentRevisionId === 'string' && + value.revisions.length > 0 && + value.revisions.every(isWorkflowRevision) && + value.revisions.some( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) && + isNullableString(value.prompt) + if (!fieldsAreValid) return false + + const currentRevision = value.revisions.find( + (revision) => isRecord(revision) && revision.id === value.currentRevisionId, + ) + if (!isRecord(currentRevision) || !Array.isArray(currentRevision.steps)) return false + if (!hasValidRevisionLine(value.revisions)) return false + + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (currentRevision.status !== expectedRevisionStatus) return false + + const activeStepCount = currentRevision.steps.filter( + (step) => isRecord(step) && step.status === 'active', + ).length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + return value.revisions.every( + (revision) => + isRecord(revision) && + Array.isArray(revision.steps) && + revision.steps.every((step) => { + if (!isRecord(step)) return false + const taskId = step.taskId + const submissionId = step.submissionId + if (taskId !== null && submissionId !== null) return false + if (taskId === null && submissionId === null) return true + // 只有 character-template 与 action-generation 允许在 active 步骤上 + // 持有任务 ID(角色图 / 动作生成任务,刷新后可恢复轮询) + return ( + (step.type === 'character-template' || step.type === 'action-generation') && + step.status === 'active' + ) + }), + ) +} + +function hasValidRevisionLine(revisions: unknown[]): boolean { + const seenRevisionIds = new Set() + const byId = new Map>() + + for (const [index, revision] of revisions.entries()) { + if ( + !isRecord(revision) || + typeof revision.id !== 'string' || + seenRevisionIds.has(revision.id) + ) { + return false + } + seenRevisionIds.add(revision.id) + + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if ( + typeof revision.basedOnRevisionId !== 'string' || + typeof revision.restartStepId !== 'string' + ) { + return false + } + const source = byId.get(revision.basedOnRevisionId) + if ( + !source || + !Array.isArray(source.steps) || + !source.steps.some( + (step) => + isRecord(step) && step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + + byId.set(revision.id, revision) + } + + return true +} + +function migrateVersionOneRun(value: unknown): WorkflowRun | null { + if (!isRecord(value) || !Array.isArray(value.revisions)) return null + + const revisions: unknown[] = [] + for (const revision of value.revisions) { + const migratedRevision = migrateVersionOneRevision(revision) + if (!migratedRevision) return null + revisions.push(migratedRevision) + } + + const migrated = { ...value, revisions } + return migrateVersionThreeRun(migrated) +} + +function migrateVersionTwoRun(value: unknown): WorkflowRun | null { + return migrateVersionThreeRun(value) +} + +function migrateVersionThreeRun(value: unknown): WorkflowRun | null { + if (!isRecord(value) || !Array.isArray(value.revisions)) return null + const revisions = value.revisions.map((revision) => { + if (!isRecord(revision) || !Array.isArray(revision.steps)) return revision + return { + ...revision, + steps: revision.steps.map((step) => { + if (!isRecord(step) || step.type !== 'action-generation' || !isRecord(step.output)) { + return step + } + if (step.output.type !== 'first_frame' || !isRecord(step.output.image)) return step + const url = step.output.image.url + if (typeof url !== 'string' || !url) return step + const actionType = + isRecord(step.input) && + ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(step.input.actionType)) + ? step.input.actionType + : 'custom' + return { + ...step, + output: { + type: 'complete_animation', + actionType, + frames: [{ url, durationMs: null }], + }, + } + }), + } + }) + const migrated = { ...value, revisions } + return isWorkflowRun(migrated) ? migrated : null +} + +function migrateVersionOneRevision(value: unknown): Record | null { + if (!isRecord(value) || typeof value.id !== 'string' || !Array.isArray(value.steps)) return null + + const steps = value.steps + const legacyOrder = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', + ] as const + if ( + steps.length !== legacyOrder.length || + !steps.every((step, index) => isRecord(step) && step.type === legacyOrder[index]) + ) { + return null + } + + const [ + characterSetup, + characterTemplate, + templateCandidate, + actionSetup, + firstFrame, + animation, + review, + ] = steps + if ( + !isRecord(characterSetup) || + !isRecord(characterTemplate) || + !isRecord(templateCandidate) || + !isRecord(actionSetup) || + !isRecord(firstFrame) || + !isRecord(animation) || + !isRecord(review) + ) { + return null + } + + const actionSteps = [actionSetup, firstFrame, animation] + const collapsedAction = + actionSteps.find((step) => step.status === 'active') ?? + actionSteps.find((step) => step.status === 'failed') ?? + (actionSteps.every((step) => step.status === 'passed') ? animation : actionSetup) + const actionStepId = `${value.id}:action-generation` + const legacyActionIds = new Set( + actionSteps.map((step) => step.id).filter((id): id is string => typeof id === 'string'), + ) + + function migrateReferences(step: Record): Record { + const referenceStepIds = Array.isArray(step.referenceStepIds) + ? step.referenceStepIds.map((id) => (legacyActionIds.has(id) ? actionStepId : id)) + : step.referenceStepIds + return { ...step, referenceStepIds } + } + + return { + ...value, + steps: [ + migrateReferences(characterSetup), + migrateReferences(characterTemplate), + migrateReferences(templateCandidate), + { + ...migrateReferences(collapsedAction), + id: actionStepId, + type: 'action-generation', + }, + migrateReferences(review), + ], + } +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + + const persisted: unknown = JSON.parse(serialized) + if (!isRecord(persisted) || !Array.isArray(persisted.runs)) return [] + + if (persisted.version === WORKFLOW_RUN_STORAGE_VERSION) { + return persisted.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } + + if (persisted.version === 1) { + return persisted.runs + .map(migrateVersionOneRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + if (persisted.version === 2) { + return persisted.runs + .map(migrateVersionTwoRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + if (persisted.version === 3) { + return persisted.runs + .map(migrateVersionThreeRun) + .filter((run): run is WorkflowRun => run !== null) + .map((run) => structuredClone(run)) + } + + return [] + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + + try { + return window.localStorage + } catch { + return null + } +} + +/** + * WorkflowRun 的内存快照是当前会话的权威状态,localStorage 只负责刷新恢复。 + * 因此 save 先更新内存;浏览器拒绝写入时,本次运行仍能继续读取和订阅。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + const listListeners = new Set() + + return { + get(runId) { + const run = runs.get(runId) + return run === undefined ? null : structuredClone(run) + }, + + getByCharacter(characterId) { + const run = [...runs.values()].find((item) => item.characterId === characterId) + return run === undefined ? null : structuredClone(run) + }, + + list() { + return [...runs.values()].map((run) => structuredClone(run)) + }, + + save(run) { + const savedRun = structuredClone(run) + if (savedRun.characterId) { + const boundRun = [...runs.values()].find( + (item) => item.id !== savedRun.id && item.characterId === savedRun.characterId, + ) + if (boundRun) { + throw new Error(`Character ${savedRun.characterId} 已经绑定到 WorkflowRun ${boundRun.id}`) + } + } + runs.set(savedRun.id, savedRun) + + 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(savedRun.id) ?? []) { + try { + listener(structuredClone(savedRun)) + } catch { + // 订阅方渲染失败不能撤销已经保存的运行状态,也不能阻断其他订阅方。 + } + } + const snapshot = [...runs.values()].map((run) => structuredClone(run)) + for (const listener of listListeners) { + try { + listener(snapshot.map((run) => structuredClone(run))) + } 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) + }, + } +} diff --git a/frontend/src/features/publish/index.test.ts b/frontend/src/features/publish/index.test.ts new file mode 100644 index 00000000..99a4d989 --- /dev/null +++ b/frontend/src/features/publish/index.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Character, CharacterApis, WorkflowRun, WorkflowStep } from '@/entities' +import { buildPlaytestPath, buildPublishedActionId, publishWorkflowRun } from './index' + +describe('buildPlaytestPath', () => { + it('uses one encoded route contract for every Playtest caller', () => { + expect( + buildPlaytestPath({ + characterId: 'character/1', + outfitId: 'default outfit', + actionId: 'walk left', + }), + ).toBe('/playtest/character%2F1/default%20outfit?actionId=walk+left') + }) +}) + +describe('publishWorkflowRun with multiple actions', () => { + it('publishes only the latest reviewed action under a step-specific id', async () => { + const firstActionId = buildPublishedActionId( + 'character-1', + 'run-1', + 'revision-1:action-generation', + ) + const character: Character = { + id: 'character-1', + projectId: 'project-1', + name: '像素骑士', + description: null, + referenceImageUrl: 'template.png', + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: '默认造型', + description: null, + previewUrl: 'template.png', + actions: [ + { + id: firstActionId, + outfitId: 'outfit-1', + name: '待机', + type: 'idle', + loop: true, + fps: 8, + frameCount: 1, + frames: [{ index: 0, imageUrl: 'idle.png', durationMs: null }], + }, + ], + }, + ], + } + const common = { + status: 'passed' as const, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + const steps: WorkflowStep[] = [ + { ...common, id: 'setup', type: 'character-setup', input: null, output: null }, + { ...common, id: 'template', type: 'character-template', input: null, output: null }, + { ...common, id: 'candidate', type: 'template-candidate', input: null, output: null }, + { + ...common, + id: 'revision-1:action-generation', + type: 'action-generation', + input: null, + output: { + type: 'complete_animation', + actionType: 'idle', + frames: [{ url: 'idle.png', durationMs: null }], + }, + }, + { ...common, id: 'revision-1:review', type: 'review', input: null, output: null }, + { + ...common, + id: 'revision-1:action-generation:2', + type: 'action-generation', + input: { + type: 'complete_animation', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionType: 'custom', + firstFrameUrl: 'template.png', + prompt: '挥手', + referenceMedia: ['template.png' as never], + }, + output: { + type: 'complete_animation', + actionType: 'custom', + frames: [{ url: 'wave.png', durationMs: 125 }], + }, + }, + { ...common, id: 'revision-1:review:2', type: 'review', input: null, output: null }, + ] + const run: WorkflowRun = { + id: 'run-1', + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + purpose: 'create_character', + driver: 'ai', + status: 'completed', + currentRevisionId: 'revision-1', + prompt: '像素骑士', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: 'completed', + steps, + generationStatus: 'completed', + exportStatus: 'not_exported', + createdAt: '2026-08-06T00:00:00.000Z', + }, + ], + } + const update = vi.fn(async (input: Character) => input) + const apis: CharacterApis = { + get: vi.fn(async () => character), + listByProject: vi.fn(), + create: vi.fn(), + update, + remove: vi.fn(), + } + + const saved = await publishWorkflowRun(apis, run) + + expect(saved.outfits[0]?.actions).toHaveLength(2) + expect(saved.outfits[0]?.actions[0]?.id).toBe(firstActionId) + expect(saved.outfits[0]?.actions[1]).toMatchObject({ + id: buildPublishedActionId('character-1', 'run-1', 'revision-1:action-generation:2'), + name: '挥手', + frames: [{ imageUrl: 'wave.png' }], + }) + }) +}) diff --git a/frontend/src/features/publish/index.ts b/frontend/src/features/publish/index.ts new file mode 100644 index 00000000..58da4075 --- /dev/null +++ b/frontend/src/features/publish/index.ts @@ -0,0 +1,91 @@ +import type { Character, CharacterApis, WorkflowRun } from '@/entities' + +/** 已经写入 Character 后端记录、可以由资产库和 Playtest 读取的目标。 */ +export interface PublishedAssetTarget { + characterId: string + outfitId: string + actionId?: string +} + +/** 发布后的页面入口。真正的资产写入由 CharacterApis 完成。 */ +export function buildPlaytestPath(target: PublishedAssetTarget): string { + const path = `/playtest/${encodeURIComponent(target.characterId)}/${encodeURIComponent(target.outfitId)}` + return target.actionId ? `${path}?${new URLSearchParams({ actionId: target.actionId })}` : path +} + +const ACTION_NAMES: Record = { + idle: '待机', + walk: '行走', + jump: '跳跃', + attack: '攻击', + custom: '自定义动作', +} + +/** 动作 ID 同时绑定 Run 与动作步骤,保证同一 Run 的多个动作不会互相覆盖。 */ +export function buildPublishedActionId( + characterId: string, + runId: string, + actionStepId: string, +): string { + return `${characterId}-${runId}-${actionStepId}` +} + +/** 审核通过时才把 WorkflowRun 中的完整动画写入正式 Character 资产树。 */ +export async function publishWorkflowRun( + characterApis: CharacterApis, + run: WorkflowRun, +): Promise { + if (!run.characterId || !run.outfitId) throw new Error('工作流还没有关联角色与造型') + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + const step = revision && findLatestReviewedAction(revision.steps) + if (!step?.output) { + throw new Error('动作生成尚未完成,不能发布') + } + const result = step.output + const character = await characterApis.get(run.characterId) + const outfit = character.outfits.find((item) => item.id === run.outfitId) + if (!outfit) throw new Error('角色中没有找到工作流关联的造型') + const actionId = buildPublishedActionId(character.id, run.id, step.id) + const action = { + id: actionId, + outfitId: outfit.id, + name: + result.actionType === 'custom' + ? step.input?.prompt?.trim() || run.prompt?.trim() || '自定义动作' + : (ACTION_NAMES[result.actionType] ?? result.actionType), + type: result.actionType, + loop: true, + fps: 8, + frameCount: result.frames.length, + frames: result.frames.map((frame, index) => ({ + index, + imageUrl: frame.url, + durationMs: frame.durationMs, + })), + } + return characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfit.id + ? { ...item, actions: [...item.actions.filter((old) => old.id !== actionId), action] } + : item, + ), + }) +} + +function findLatestReviewedAction(steps: WorkflowRun['revisions'][number]['steps']) { + for (let index = steps.length - 2; index >= 3; index -= 1) { + const action = steps[index] + const review = steps[index + 1] + if ( + action?.type === 'action-generation' && + action.status === 'passed' && + action.output && + review?.type === 'review' && + review.status === 'passed' + ) { + return action + } + } + return null +} diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md new file mode 100644 index 00000000..868cf3c5 --- /dev/null +++ b/frontend/src/features/workflow-controller/README.md @@ -0,0 +1,20 @@ +# WorkflowController + +WorkflowController 是页面共用的流程协调器。Quick Start 可以自动连续调用它,Workflow Editor 可以按 +用户点击逐步调用它;两种页面不能各自维护另一套状态机。 + +## 职责 + +- 从 `WorkflowRunStore` 读取和保存同一个 `WorkflowRun`。 +- 调用 Generation APIs 创建角色母版和动作生成任务,并处理刷新恢复。 +- 调用纯状态函数推进 Step、追加动作、审核、暂停和从历史节点重启。 +- 审核通过后调用发布能力写入正式 Character 资产。 + +Controller 不定义第二种 WorkflowRun,不负责 Playtest 状态,也不直接调用模型供应商。 + +## 文件 + +- `controller.ts`:页面命令入口。 +- `workflow-state.ts`:纯状态转换。 +- `character-template-task.ts`、`action-generation-task.ts`:异步生成任务生命周期。 +- `*.test.ts`:状态、存储约束和完整流程测试。 diff --git a/frontend/src/features/workflow-controller/action-generation-task.ts b/frontend/src/features/workflow-controller/action-generation-task.ts new file mode 100644 index 00000000..7d9ec16b --- /dev/null +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -0,0 +1,252 @@ +import { + COMPLETE_ANIMATION_FRAME_COUNT, + type CompleteAnimationGenerationInput, + type CompleteAnimationGenerationResult, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRun, + type WorkflowRunStore, +} from '@/entities' +import { + beginActionGenerationState, + completeActionGenerationState, + getActiveStep, + getCurrentRevision, + recordActionGenerationTaskState, +} from './workflow-state' + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface ActionGenerationTask { + start(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput): Promise + resume(runId: WorkflowRun['id']): Promise + stop(runId: WorkflowRun['id']): void +} + +interface CreateActionGenerationTaskOptions { + store: WorkflowRunStore + generationApis: GenerationApis + createSubmissionId: () => string +} + +/** 管理完整动作生成的提交、订阅和刷新恢复,页面只负责提供业务输入。 */ +export function createActionGenerationTask({ + store, + generationApis, + createSubmissionId, +}: CreateActionGenerationTaskOptions): ActionGenerationTask { + const submissions = new Map>() + const subscriptions = new Map() + + function requireRun(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function currentActionStep(runId: WorkflowRun['id']) { + const run = requireRun(runId) + const revision = getCurrentRevision(run) + const step = getActiveStep(revision) + return { run, revision, step } + } + + function start(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput) { + const { run, revision, step } = currentActionStep(runId) + if (run.status !== 'active' || step?.type !== 'action-generation') return Promise.resolve(run) + if (step.taskId) { + subscribe(run, step.taskId) + return Promise.resolve(run) + } + if (step.submissionId) throw new Error('动作生成请求仍在等待后端确认,不能重复提交') + + const key = `${runId}:${revision.id}:${step.id}` + const pending = submissions.get(key) + if (pending) return pending + const submission = submit(runId, input).finally(() => submissions.delete(key)) + submissions.set(key, submission) + return submission + } + + async function submit(runId: WorkflowRun['id'], input: CompleteAnimationGenerationInput) { + const submissionId = createSubmissionId() + save(beginActionGenerationState(requireRun(runId), input, submissionId)) + try { + const generation = await generationApis.create(input) + const latest = requireRun(runId) + const revision = getCurrentRevision(latest) + const step = getActiveStep(revision) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + step?.type !== 'action-generation' || + step.submissionId !== submissionId + ) { + return latest + } + if (generation.type !== 'complete_animation') { + throw new Error('生成任务类型与动作生成步骤不匹配') + } + const withTask = save(recordActionGenerationTaskState(latest, generation.id, input)) + if (latest.status === 'interrupted') return withTask + if (generation.status === 'pending' || generation.status === 'running') { + subscribe(withTask, generation.id) + return withTask + } + return applyTerminal(runId, generation.id, generation) + } catch (cause) { + const latest = store.get(runId) + if (latest?.status === 'active') { + const step = getActiveStep(getCurrentRevision(latest)) + if (step?.type === 'action-generation') { + save(completeActionGenerationState(latest, { error: message(cause, '动作生成请求失败') })) + } + } + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function subscribe(run: WorkflowRun, taskId: string) { + const key = `${run.id}:${taskId}` + if (subscriptions.has(key)) return + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + if (event.taskId !== taskId || event.status === 'pending' || event.status === 'running') + return + applyTerminal(run.id, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function applyTerminal( + runId: WorkflowRun['id'], + taskId: string, + task: Generation | GenerationEvent, + ) { + const latest = requireRun(runId) + if (latest.status !== 'active') return latest + const step = getActiveStep(getCurrentRevision(latest)) + if (step?.type !== 'action-generation' || step.taskId !== taskId) return latest + stopSubscription(runId, taskId) + if (task.status === 'failed') { + return save( + completeActionGenerationState(latest, { + error: task.error?.trim() || '动作生成任务失败', + }), + ) + } + const result = task.result + if ( + task.type !== 'complete_animation' || + result?.type !== 'complete_animation' || + result.frames.length === 0 + ) { + return save( + completeActionGenerationState(latest, { error: '动作生成完成但未返回有效动画帧' }), + ) + } + const completeResult = result as CompleteAnimationGenerationResult + const frameCountError = getCompleteAnimationFrameCountError(completeResult) + return save( + completeActionGenerationState( + latest, + frameCountError ? { error: frameCountError } : completeResult, + ), + ) + } + + async function resume(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run || run.status !== 'active') return run + const step = getActiveStep(getCurrentRevision(run)) + if (step?.type !== 'action-generation') return run + if (step.submissionId && !step.taskId) { + return save( + completeActionGenerationState(run, { + error: '页面刷新时动作生成请求尚未返回任务 ID,请重新开始该步骤', + }), + ) + } + if (!step.taskId) { + if (step.input) return start(runId, step.input) + return save( + completeActionGenerationState(run, { + error: '动作生成尚未完成提交,请重新确认角色候选', + }), + ) + } + try { + const task = await generationApis.get(run.projectId, step.taskId) + if (task.status === 'pending' || task.status === 'running') { + subscribe(run, step.taskId) + return store.get(runId) + } + return applyTerminal(runId, step.taskId, task) + } catch (cause) { + const latest = store.get(runId) + if (!latest || latest.status !== 'active') return latest + return save( + completeActionGenerationState(latest, { + error: message(cause, '恢复动作生成任务失败'), + }), + ) + } + } + + function stopSubscription(runId: string, taskId: string) { + const key = `${runId}:${taskId}` + const active = subscriptions.get(key) + subscriptions.delete(key) + try { + active?.stop() + } catch { + // 停止订阅失败不能破坏已经保存的工作流状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, active] of subscriptions) { + if (active.runId !== runId) continue + subscriptions.delete(key) + try { + active.stop() + } catch { + // 同上。 + } + } + } + + return { start, resume, stop } +} + +/** + * Controller 的完整动画验收门槛。生成服务负责补帧,WorkflowRun 只接收恰好 32 帧的 + * 新结果;这里不修改结果数组,避免把后端缺帧静默伪装成成功。 + */ +export function getCompleteAnimationFrameCountError( + result: CompleteAnimationGenerationResult, +): string | null { + const actualFrameCount = result.frames.length + return actualFrameCount === COMPLETE_ANIMATION_FRAME_COUNT + ? null + : `动作生成应返回 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际返回 ${actualFrameCount} 帧` +} + +function message(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} diff --git a/frontend/src/features/workflow-controller/character-template-task.ts b/frontend/src/features/workflow-controller/character-template-task.ts new file mode 100644 index 00000000..80002d70 --- /dev/null +++ b/frontend/src/features/workflow-controller/character-template-task.ts @@ -0,0 +1,464 @@ +import { + parseCharacterTemplateGenerationResult, + type Generation, + type GenerationApis, + type GenerationEvent, + type WorkflowRevision, + type WorkflowRun, + type WorkflowRunStore, + type WorkflowStep, +} from '@/entities' +import { + getActiveStep, + getCurrentRevision, + replaceWorkflowStep, + type WorkflowStepTarget, +} from './workflow-state' + +interface ApplyServerResultInput extends WorkflowStepTarget { + /** 结果必须仍属于步骤当前记录的任务;重试前的旧结果会被忽略。 */ + taskId: string + result: unknown +} + +interface ActiveSubscription { + runId: WorkflowRun['id'] + stop: () => void +} + +export interface CharacterTemplateTask { + /** 启动或继续目标角色图步骤;同一实例内的重复调用共享一次提交。 */ + start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 停止指定运行记录的前端任务订阅,不改变 WorkflowRun 状态。 */ + stop(runId: WorkflowRun['id']): void +} + +interface CreateCharacterTemplateTaskOptions { + store: WorkflowRunStore + generationApis: GenerationApis + createSubmissionId: () => string +} + +/** + * 角色图异步任务的生命周期。 + * + * 它只处理当前角色图步骤与后端 Generation 的关联,不决定整个工作流下一步走什么。 + * submissions 与 subscriptions 属于实例锁;生产环境必须复用同一个实例。 + */ +export function createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId, +}: CreateCharacterTemplateTaskOptions): CharacterTemplateTask { + const submissions = new Map>() + const subscriptions = new Map() + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function start(runId: WorkflowRun['id'], target: WorkflowStepTarget): Promise { + const run = requireWorkflow(runId) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + revision.id !== target.revisionId || + !step || + step.type !== 'character-template' || + step.status !== 'active' + ) { + return Promise.resolve(run) + } + if (step.taskId) { + ensureTaskSubscription(run, target.revisionId, target.stepId, step.taskId) + return Promise.resolve(requireWorkflow(runId)) + } + if (!step.input) throw new Error('角色图生成步骤缺少输入快照') + return submit(runId, target) + } + + function submit(runId: WorkflowRun['id'], target: WorkflowStepTarget) { + const key = submissionKey(runId, target.revisionId, target.stepId) + const pending = submissions.get(key) + if (pending) return pending + + const submission = performSubmission(runId, target).finally(() => { + submissions.delete(key) + }) + submissions.set(key, submission) + return submission + } + + async function performSubmission( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + ): Promise { + const before = requireWorkflow(runId) + const beforeRevision = getCurrentRevision(before) + const beforeStep = beforeRevision.steps.find((step) => step.id === target.stepId) + if ( + before.status !== 'active' || + beforeRevision.id !== target.revisionId || + !beforeStep || + beforeStep.type !== 'character-template' || + beforeStep.status !== 'active' || + !beforeStep.input + ) { + return before + } + if (beforeStep.taskId) { + ensureTaskSubscription(before, target.revisionId, target.stepId, beforeStep.taskId) + return before + } + if (beforeStep.submissionId) { + throw new Error('角色图生成请求仍在等待后端确认,不能重复提交') + } + + const submissionId = createSubmissionId() + const submitting = replaceWorkflowStep(before, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, submissionId } + }) + save(submitting) + + try { + const generation = await generationApis.create(beforeStep.input) + const latest = requireWorkflow(runId) + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === target.stepId) + if ( + (latest.status !== 'active' && latest.status !== 'interrupted') || + latestRevision.id !== target.revisionId || + !latestStep || + latestStep.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId || + latestStep.submissionId !== submissionId + ) { + return latest + } + const typeOk = + generation.type === 'character_template' || generation.type === 'character_image' + // project_id 有一方为 null/undefined 时容忍(后端可能未返回);双方都有值时必须一致 + const projectOk = + generation.projectId == null || + latest.projectId == null || + String(generation.projectId) === String(latest.projectId) + if (!typeOk || !projectOk) { + throw new Error( + `生成任务返回的类型或项目与当前 WorkflowRun 不匹配 ` + + `(type: ${generation.type}, project: ${generation.projectId} vs ${latest.projectId})`, + ) + } + + const withTask = replaceWorkflowStep(latest, target.revisionId, target.stepId, (current) => { + if (current.type !== 'character-template') return current + return { ...current, taskId: generation.id, submissionId: null } + }) + save(withTask) + + if (latest.status === 'interrupted') return withTask + if (generation.status === 'failed') { + return markFailed( + runId, + target, + generation.id, + null, + generation.error?.trim() || '角色图生成任务失败', + ) + } + if (generation.status === 'completed') { + return applyServerResult(runId, { + ...target, + taskId: generation.id, + result: generation.result, + }) + } + + ensureTaskSubscription(withTask, target.revisionId, target.stepId, generation.id) + return requireWorkflow(runId) + } catch (cause) { + markFailed(runId, target, null, submissionId, errorMessage(cause, '角色图生成请求失败')) + throw cause instanceof Error ? cause : new Error(String(cause)) + } + } + + function ensureTaskSubscription( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, + ) { + const key = subscriptionKey(run.id, revisionId, stepId, taskId) + if (subscriptions.has(key)) return + + subscriptions.set(key, { runId: run.id, stop: () => undefined }) + try { + const stop = generationApis.subscribe(run.projectId, taskId, (event) => { + handleGenerationEvent(run.id, { revisionId, stepId }, taskId, event) + }) + const active = subscriptions.get(key) + if (active) subscriptions.set(key, { ...active, stop }) + else stop() + } catch (cause) { + subscriptions.delete(key) + throw cause + } + } + + function handleGenerationEvent( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + taskId: string, + event: GenerationEvent, + ) { + if (event.taskId !== taskId) return + if (event.status === 'pending' || event.status === 'running') return + if (event.status === 'failed') { + markFailed(runId, target, taskId, null, event.error?.trim() || '角色图生成任务失败') + return + } + if (event.type !== 'character_template') { + markFailed(runId, target, taskId, null, '任务结果类型与角色图生成步骤不匹配') + return + } + applyServerResult(runId, { + ...target, + taskId, + result: event.result, + }) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = getWorkflow(runId) + if (!run || run.status !== 'active') return run + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (activeStep?.type !== 'character-template' || activeStep.status !== 'active') { + return run + } + const target = { revisionId: revision.id, stepId: activeStep.id } + + if (activeStep.submissionId && !activeStep.taskId) { + if (submissions.has(submissionKey(run.id, revision.id, activeStep.id))) { + return run + } + return markFailed( + run.id, + target, + null, + activeStep.submissionId, + '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + ) + } + if (activeStep.taskId) { + const task = await generationApis.get(run.projectId, activeStep.taskId) + const latest = getWorkflow(run.id) + if (!latest || latest.status !== 'active' || latest.currentRevisionId !== revision.id) { + return latest + } + const latestRevision = getCurrentRevision(latest) + const latestStep = latestRevision.steps.find((step) => step.id === activeStep.id) + if ( + latestStep?.type !== 'character-template' || + latestStep.status !== 'active' || + latestStep.taskId !== activeStep.taskId + ) { + return latest + } + if (task.id !== latestStep.taskId) { + throw new Error('任务查询结果与 WorkflowRun 记录的 taskId 不匹配') + } + if (task.type !== 'character_template') { + return markFailed( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + null, + '任务查询结果类型与角色图生成步骤不匹配', + ) + } + if (task.status === 'pending' || task.status === 'running') { + ensureTaskSubscription(latest, latestRevision.id, latestStep.id, latestStep.taskId) + } else { + handleGenerationEvent( + latest.id, + { revisionId: latestRevision.id, stepId: latestStep.id }, + latestStep.taskId, + taskEvent(task), + ) + } + } + return getWorkflow(runId) + } + + function applyServerResult(runId: WorkflowRun['id'], input: ApplyServerResultInput): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== input.revisionId) { + return run + } + + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === input.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + step.taskId !== input.taskId + ) { + return run + } + + const result = parseCharacterTemplateGenerationResult(input.result) + if (!result) { + return markFailed( + runId, + { revisionId: revision.id, stepId: step.id }, + input.taskId, + null, + '角色图生成任务返回了无法识别的结果', + ) + } + const candidateStep = revision.steps.find((item) => item.type === 'template-candidate') + if (!candidateStep) throw new Error('WorkflowRun 缺少 template-candidate 步骤') + + const updated: WorkflowRun = { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((current) => { + if (current.id === step.id && current.type === 'character-template') { + return { + ...current, + status: 'passed' as const, + output: result, + taskId: null, + submissionId: null, + } + } + if (current.id === candidateStep.id && current.type === 'template-candidate') { + return { ...current, status: 'active' as const } + } + return current + }), + } + }), + } + stopSubscription(subscriptionKey(run.id, revision.id, step.id, input.taskId)) + return save(updated) + } + + function markFailed( + runId: WorkflowRun['id'], + target: WorkflowStepTarget, + expectedTaskId: string | null, + expectedSubmissionId: string | null, + error: string, + ) { + const run = requireWorkflow(runId) + if (run.status !== 'active' || run.currentRevisionId !== target.revisionId) return run + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.id === target.stepId) + if ( + !step || + step.type !== 'character-template' || + step.status !== 'active' || + (expectedTaskId !== null && step.taskId !== expectedTaskId) || + (expectedSubmissionId !== null && step.submissionId !== expectedSubmissionId) + ) { + return run + } + + const failureMessage = error.trim() || '角色图生成失败' + const failed: WorkflowRun = { + ...replaceWorkflowStep( + run, + target.revisionId, + target.stepId, + (current) => ({ + ...current, + status: 'failed', + taskId: null, + submissionId: null, + error: failureMessage, + }), + (current) => ({ + ...current, + status: 'failed', + generationStatus: 'failed', + }), + ), + status: 'failed', + } + if (step.taskId) { + stopSubscription(subscriptionKey(run.id, revision.id, step.id, step.taskId)) + } + return save(failed) + } + + function stopSubscription(key: string) { + const subscription = subscriptions.get(key) + subscriptions.delete(key) + try { + subscription?.stop() + } catch { + // 取消轮询失败不能反向破坏已经落盘的 WorkflowRun 状态。 + } + } + + function stop(runId: WorkflowRun['id']) { + for (const [key, subscription] of subscriptions) { + if (subscription.runId === runId) stopSubscription(key) + } + } + + return { start, resume, stop } +} + +function taskEvent(task: Generation): GenerationEvent { + return { + taskId: task.id, + type: task.type, + status: task.status, + error: task.error, + result: task.result, + } +} + +function errorMessage(cause: unknown, fallback: string) { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function subscriptionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + taskId: string, +) { + return `${runId}:${revisionId}:${stepId}:${taskId}` +} + +function submissionKey( + runId: WorkflowRun['id'], + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], +) { + return `${runId}:${revisionId}:${stepId}` +} diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 00000000..9f0a523a --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,882 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + COMPLETE_ANIMATION_FRAME_COUNT, + WORKFLOW_STEP_ORDER, + type Character, + type CharacterApis, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from '.' + +const NOW = '2026-07-30T12:00:00.000Z' + +type RunListener = (run: WorkflowRun) => void + +function cloneRun(run: WorkflowRun): WorkflowRun { + return structuredClone(run) +} + +function createMemoryStore() { + const runs = new Map() + const listeners = new Map>() + const listListeners = new Set<(runs: WorkflowRun[]) => void>() + + const get = vi.fn((runId: string): WorkflowRun | null => { + const run = runs.get(runId) + return run ? cloneRun(run) : null + }) + + const save = vi.fn((run: WorkflowRun): void => { + const snapshot = cloneRun(run) + runs.set(run.id, snapshot) + + for (const listener of listeners.get(run.id) ?? []) { + listener(cloneRun(snapshot)) + } + for (const listener of listListeners) listener([...runs.values()].map(cloneRun)) + }) + + const subscribe = vi.fn((runId: string, listener: RunListener): (() => void) => { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + + return () => { + runListeners.delete(listener) + } + }) + + const list = vi.fn(() => [...runs.values()].map(cloneRun)) + const getByCharacter = vi.fn((characterId: string) => { + const run = [...runs.values()].find((item) => item.characterId === characterId) + return run ? cloneRun(run) : null + }) + const subscribeAll = vi.fn((listener: (runs: WorkflowRun[]) => void) => { + listListeners.add(listener) + return () => listListeners.delete(listener) + }) + + return { get, getByCharacter, list, save, subscribe, subscribeAll } +} + +function createIdFactory() { + let nextId = 0 + return vi.fn(() => `id-${++nextId}`) +} + +function deferNextGeneration(harness: ReturnType) { + let resolve!: (generation: Generation<'character_template'>) => void + const promise = new Promise>((resolvePromise) => { + resolve = resolvePromise + }) + vi.mocked(harness.generationApis.create).mockImplementationOnce( + async () => (await promise) as Generation, + ) + return { resolve } +} + +function pendingCharacterTemplateGeneration(): Generation<'character_template'> { + return { + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'pending', + result: null, + error: null, + } +} + +function createHarness() { + const store = createMemoryStore() + const taskListeners = new Map void>() + + const createGeneration: GenerationApis['create'] = async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const subscribeTask = vi.fn( + (projectId: string, taskId: string, onEvent: (event: GenerationEvent) => void) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }, + ) + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error('GenerationApis.get is not used until a run is resumed') + }), + subscribe: subscribeTask, + } + const character: Character = { + id: 'character-1', + projectId: 'project-1', + name: '默认角色', + description: null, + referenceImageUrl: 'https://example.com/knight.png', + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: '默认造型', + description: null, + previewUrl: 'https://example.com/knight.png', + actions: [], + }, + ], + } + const characterApis: CharacterApis = { + get: vi.fn(async () => character), + listByProject: vi.fn(async () => ({ + items: [character], + total: 1, + page: 1, + pageSize: 20, + })), + create: vi.fn(async () => character), + update: vi.fn(async (input) => input), + remove: vi.fn(async () => undefined), + } + + const controller = createWorkflowController({ + store, + generationApis, + characterApis, + createId: createIdFactory(), + now: () => NOW, + }) + + return { + controller, + characterApis, + generationApis, + subscribeTask, + store, + getTaskListener(projectId: string, taskId: string) { + return taskListeners.get(`${projectId}:${taskId}`) ?? null + }, + emitTask(projectId: string, taskId: string, event: GenerationEvent) { + const listener = taskListeners.get(`${projectId}:${taskId}`) + expect(listener, `missing task subscription for ${projectId}:${taskId}`).toBeTypeOf( + 'function', + ) + listener?.(event) + }, + } +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find(({ id }) => id === run.currentRevisionId) + if (!revision) { + throw new Error(`Current revision ${run.currentRevisionId} is missing`) + } + return revision +} + +function step(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + const workflowStep = currentRevision(run).steps.find((item) => item.type === type) + if (!workflowStep) { + throw new Error(`Workflow step ${type} is missing`) + } + return workflowStep +} + +async function createAiRun(harness: ReturnType) { + return harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }) +} + +const SPRITE_SIZE = { width: 64, height: 64 } + +async function startCharacterTemplate(harness: ReturnType) { + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + return run +} + +describe('createWorkflowController', () => { + it('creates one revision with the fixed five steps and seeds AI input from the prompt', async () => { + const harness = createHarness() + + expect(harness.controller).toEqual( + expect.objectContaining({ + create: expect.any(Function), + getWorkflow: expect.any(Function), + subscribe: expect.any(Function), + updateCharacterSetup: expect.any(Function), + nextStep: expect.any(Function), + restart: expect.any(Function), + resume: expect.any(Function), + interrupt: expect.any(Function), + }), + ) + + const run = await createAiRun(harness) + const revision = currentRevision(run) + + expect(run.prompt).toBe('pixel knight') + expect(run.projectId).toBe('project-1') + expect(run.revisions).toHaveLength(1) + expect(run.currentRevisionId).toBe(revision.id) + expect(revision.basedOnRevisionId).toBeNull() + expect(revision.restartStepId).toBeNull() + expect(revision.createdAt).toBe(NOW) + expect(revision.steps.map(({ type }) => type)).toEqual(WORKFLOW_STEP_ORDER) + expect(revision.steps.map(({ status }) => status)).toEqual([ + 'active', + 'locked', + 'locked', + 'locked', + 'locked', + ]) + expect(step(run, 'character-setup').input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + + const allIds = [run.id, revision.id, ...revision.steps.map(({ id }) => id)] + expect(new Set(allIds).size).toBe(allIds.length) + expect(harness.store.save).toHaveBeenCalledWith(run) + }) + + it('locates the character run and appends a new action to that same run', async () => { + const harness = createHarness() + const created = await createAiRun(harness) + const completed = { + ...created, + characterId: 'character-1', + outfitId: 'outfit-1', + status: 'completed' as const, + revisions: created.revisions.map((revision) => ({ + ...revision, + status: 'completed' as const, + generationStatus: 'completed' as const, + steps: revision.steps.map((item) => ({ ...item, status: 'passed' as const })), + })), + } + harness.store.save(completed) + + expect(harness.controller.getWorkflowByCharacter('character-1')?.id).toBe(created.id) + const appended = harness.controller.appendAction(created.id) + + expect(appended.id).toBe(created.id) + expect(currentRevision(appended).steps).toHaveLength(7) + expect(currentRevision(appended).steps.at(-2)).toMatchObject({ + type: 'action-generation', + status: 'active', + }) + }) + + it('persists the selected character and starts its action through one controller command', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + await Promise.resolve() + vi.mocked(harness.generationApis.create).mockClear() + + const started = await harness.controller.startActionFromTemplate( + run.id, + 'https://example.com/knight.png', + '挥手', + ) + + expect(harness.characterApis.create).toHaveBeenCalledWith({ + projectId: 'project-1', + description: 'Workflow auto-created character', + referenceImageUrl: 'https://example.com/knight.png', + }) + expect(started).toMatchObject({ characterId: 'character-1', outfitId: 'outfit-1' }) + expect(harness.generationApis.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'complete_animation', + characterId: 'character-1', + outfitId: 'outfit-1', + prompt: '挥手', + referenceMedia: ['https://example.com/knight.png'], + }), + ) + }) + + it('persists the task id before subscribing and never submits the active generation twice', async () => { + const harness = createHarness() + + await startCharacterTemplate(harness) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(harness.generationApis.create).toHaveBeenCalledWith({ + type: 'character_template', + projectId: 'project-1', + prompt: 'pixel knight', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }) + expect(harness.subscribeTask).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + + const taskSaveIndex = harness.store.save.mock.calls.findIndex(([savedRun]) => { + return step(savedRun, 'character-template').taskId === 'task-1' + }) + expect(taskSaveIndex).toBeGreaterThanOrEqual(0) + expect(harness.store.save.mock.invocationCallOrder[taskSaveIndex]).toBeLessThan( + harness.subscribeTask.mock.invocationCallOrder[0], + ) + + const createdRun = harness.store.save.mock.calls[0]?.[0] + if (!createdRun) throw new Error('Expected the created WorkflowRun to be saved') + const activeRun = harness.controller.getWorkflow(createdRun.id) + if (!activeRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(activeRun, 'character-setup').status).toBe('passed') + expect(step(activeRun, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + }) + + await harness.controller.nextStep(activeRun.id) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('shares one submission when nextStep is called concurrently', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const first = harness.controller.nextStep(run.id, SPRITE_SIZE) + const second = harness.controller.nextStep(run.id, SPRITE_SIZE) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + deferred.resolve(pendingCharacterTemplateGeneration()) + await Promise.all([first, second]) + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('keeps an active submission alive when resume uses the same controller', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) + const resumed = await harness.controller.resume(run.id) + + expect(resumed?.status).toBe('active') + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'active', + taskId: null, + submissionId: expect.any(String), + }) + + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template').taskId).toBe( + 'task-1', + ) + }) + + it('handles a terminal snapshot emitted synchronously when subscribing', async () => { + const harness = createHarness() + const stop = vi.fn() + harness.subscribeTask.mockImplementationOnce((_projectId, _taskId, onEvent) => { + onEvent({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/synchronous.png' }], + }, + }) + return stop + }) + + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + + expect(step(harness.controller.getWorkflow(run.id)!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(harness.controller.getWorkflow(run.id)!, 'template-candidate').status).toBe( + 'active', + ) + expect(stop).toHaveBeenCalledOnce() + }) + + it('ignores another task result and advances only when the matching task completes', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'another-task', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/wrong.png' }], + }, + }) + + const unchangedRun = harness.controller.getWorkflow(run.id) + if (!unchangedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(unchangedRun, 'character-template')).toMatchObject({ + status: 'active', + output: null, + taskId: 'task-1', + }) + expect(step(unchangedRun, 'template-candidate').status).toBe('locked') + + const result = { + type: 'character_template' as const, + images: [{ url: 'https://example.com/knight.png' }], + } + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result, + }) + + const completedRun = harness.controller.getWorkflow(run.id) + if (!completedRun) throw new Error('Expected the WorkflowRun to remain available') + expect(step(completedRun, 'character-template')).toMatchObject({ + status: 'passed', + output: result, + taskId: null, + }) + expect(step(completedRun, 'template-candidate').status).toBe('active') + }) + + it('marks the step, revision, generation, and run as failed when the task fails', async () => { + const harness = createHarness() + + const run = await startCharacterTemplate(harness) + + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'model unavailable', + result: null, + }) + + const failedRun = harness.controller.getWorkflow(run.id) + if (!failedRun) throw new Error('Expected the WorkflowRun to remain available') + const revision = currentRevision(failedRun) + expect(step(failedRun, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: 'model unavailable', + }) + expect(revision.status).toBe('failed') + expect(revision.generationStatus).toBe('failed') + expect(failedRun.status).toBe('failed') + }) + + it('resumes a persisted in-flight task without creating another generation', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(resumed?.id).toBe(run.id) + expect(harness.generationApis.get).toHaveBeenCalledWith('project-1', 'task-1') + expect(resumeSubscribe).toHaveBeenCalledWith('project-1', 'task-1', expect.any(Function)) + expect(harness.generationApis.create).toHaveBeenCalledTimes(1) + }) + + it('applies a completed task found during refresh before subscribing again', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + vi.mocked(harness.generationApis.get).mockResolvedValueOnce({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/recovered.png' }], + }, + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { ...harness.generationApis, subscribe: resumeSubscribe }, + }) + + const resumed = await resumedController.resume(run.id) + + expect(step(resumed!, 'character-template')).toMatchObject({ + status: 'passed', + taskId: null, + }) + expect(step(resumed!, 'template-candidate').status).toBe('active') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe after interrupting while resume waits for the task query', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + let resolveTask!: (task: Generation) => void + const pendingTask = new Promise((resolve) => { + resolveTask = resolve + }) + const resumeSubscribe = vi.fn( + (_projectId: string, _taskId: string, _onEvent: (event: GenerationEvent) => void) => () => + undefined, + ) + const resumedController = createWorkflowController({ + store: harness.store, + generationApis: { + ...harness.generationApis, + get: vi.fn(() => pendingTask), + subscribe: resumeSubscribe, + }, + }) + + const resuming = resumedController.resume(run.id) + resumedController.interrupt(run.id) + resolveTask({ + id: 'task-1', + projectId: 'project-1', + type: 'character_template', + status: 'running', + error: null, + result: null, + }) + const resumed = await resuming + + expect(resumed?.status).toBe('interrupted') + expect(resumeSubscribe).not.toHaveBeenCalled() + }) + + it('fails safely after refresh when the request was sent before taskId arrived', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const uncertainSnapshot = harness.store.save.mock.calls + .map(([savedRun]) => savedRun) + .find((savedRun) => { + const template = step(savedRun, 'character-template') + return template.submissionId !== null && template.taskId === null + }) + if (!uncertainSnapshot) throw new Error('expected the submitting snapshot to be persisted') + harness.store.save(uncertainSnapshot) + vi.mocked(harness.generationApis.create).mockClear() + + const restoredController = createWorkflowController({ + store: harness.store, + generationApis: harness.generationApis, + }) + + const restored = await restoredController.resume(run.id) + + expect(restored?.status).toBe('failed') + expect(step(restored!, 'character-template')).toMatchObject({ + status: 'failed', + taskId: null, + submissionId: null, + error: '页面刷新时生成请求尚未返回任务 ID,已停止恢复以避免重复提交', + }) + expect(harness.generationApis.create).not.toHaveBeenCalled() + }) + + it('records a task id that returns after the run was interrupted', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const deferred = deferNextGeneration(harness) + + const submission = harness.controller.nextStep(run.id, SPRITE_SIZE) + await harness.controller.interrupt(run.id) + deferred.resolve(pendingCharacterTemplateGeneration()) + await submission + + const interrupted = harness.controller.getWorkflow(run.id) + expect(interrupted?.status).toBe('interrupted') + expect(step(interrupted!, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + submissionId: null, + }) + expect(harness.subscribeTask).not.toHaveBeenCalled() + }) + + it('keeps an interrupted run interrupted when a queued failure arrives late', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + const queuedListener = harness.getTaskListener('project-1', 'task-1') + if (!queuedListener) throw new Error('expected an active task subscription') + + await harness.controller.interrupt(run.id) + queuedListener({ + taskId: 'task-1', + type: 'character_template', + status: 'failed', + error: 'late failure', + result: null, + }) + + expect(harness.controller.getWorkflow(run.id)?.status).toBe('interrupted') + }) + + it('publishes saved updates and can interrupt the current run', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + const listener = vi.fn() + const unsubscribe = harness.controller.subscribe(run.id, listener) + + const updated = await harness.controller.updateCharacterSetup(run.id, { + description: 'revised knight', + referenceMedia: [], + }) + expect(step(updated, 'character-setup').input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + + const interrupted = await harness.controller.interrupt(run.id) + + expect(interrupted.status).toBe('interrupted') + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ id: run.id, status: 'interrupted' }), + ) + + unsubscribe() + }) + + it('completes the active action-generation step without throwing and activates review', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + const confirmed = harness.controller.confirmCandidate( + run.id, + 'https://example.com/candidate.png', + ) + + expect(step(confirmed, 'template-candidate')).toMatchObject({ + status: 'passed', + output: { selectedImageUrl: 'https://example.com/candidate.png' }, + }) + expect(step(confirmed, 'action-generation').status).toBe('active') + + const result = { + type: 'complete_animation' as const, + actionType: 'idle' as const, + frames: Array.from({ length: COMPLETE_ANIMATION_FRAME_COUNT }, (_, index) => ({ + url: `https://example.com/frame-${index}.png`, + durationMs: 125, + })), + } + const completed = harness.controller.completeActionGeneration(run.id, result) + + expect(step(completed, 'action-generation')).toMatchObject({ + status: 'passed', + output: result, + error: null, + }) + // 动作完成后 review 进入 active,保证刷新后 run 仍满足“恰好一个 active 步骤”的存储校验 + expect(step(completed, 'review').status).toBe('active') + }) + + it('rejects an animation result that does not contain the required 32 frames', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + harness.controller.confirmCandidate(run.id, 'https://example.com/candidate.png') + + const failed = harness.controller.completeActionGeneration(run.id, { + type: 'complete_animation', + actionType: 'idle', + frames: Array.from({ length: 7 }, (_, index) => ({ + url: `https://example.com/frame-${index}.png`, + durationMs: 125, + })), + }) + + expect(step(failed, 'action-generation')).toMatchObject({ + status: 'failed', + output: null, + error: '动作生成应返回 32 帧,实际返回 7 帧', + }) + expect(step(failed, 'review').status).toBe('locked') + }) + + it('rejects an underfilled animation received from the generation subscription', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + + await harness.controller.startActionFromTemplate( + run.id, + 'https://example.com/candidate.png', + '挥手', + ) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'complete_animation', + status: 'completed', + error: null, + result: { + type: 'complete_animation', + actionType: 'custom', + frames: Array.from({ length: 7 }, (_, index) => ({ + url: `https://example.com/frame-${index}.png`, + durationMs: 125, + })), + }, + }) + + const failed = harness.controller.getWorkflow(run.id) + if (!failed) throw new Error('Expected the workflow to remain available') + expect(step(failed, 'action-generation')).toMatchObject({ + status: 'failed', + output: null, + error: '动作生成应返回 32 帧,实际返回 7 帧', + }) + expect(step(failed, 'review').status).toBe('locked') + }) + + it('marks the action-generation step failed when the result carries an error', async () => { + const harness = createHarness() + const run = await startCharacterTemplate(harness) + harness.emitTask('project-1', 'task-1', { + taskId: 'task-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/candidate.png' }], + }, + }) + harness.controller.confirmCandidate(run.id, 'https://example.com/candidate.png') + + const failed = harness.controller.completeActionGeneration(run.id, { + error: '动作生成完成但未返回有效帧图片', + }) + + expect(step(failed, 'action-generation')).toMatchObject({ + status: 'failed', + error: '动作生成完成但未返回有效帧图片', + }) + expect(step(failed, 'review').status).toBe('locked') + expect(failed.status).toBe('failed') + }) + + it('restarts from a passed stage as a new local revision', async () => { + const harness = createHarness() + const run = await createAiRun(harness) + await harness.controller.nextStep(run.id, SPRITE_SIZE) + const advanced = harness.controller.getWorkflow(run.id) + if (!advanced) throw new Error('Expected the workflow to remain available') + const original = currentRevision(advanced) + const restarted = harness.controller.restart(advanced.id, `${original.id}:character-setup`) + + const revision = currentRevision(restarted) + expect(restarted.status).toBe('active') + expect(restarted.revisions).toHaveLength(2) + expect(restarted.revisions[0]?.status).toBe('abandoned') + expect(revision).toMatchObject({ + id: 'id-4', + basedOnRevisionId: original.id, + restartStepId: `${original.id}:character-setup`, + createdAt: NOW, + }) + expect(step(restarted, 'character-setup')).toMatchObject({ + id: 'id-4:character-setup', + status: 'active', + referenceStepIds: [`${original.id}:character-setup`], + }) + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 00000000..dc473267 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,484 @@ +import type { + CharacterApis, + CharacterSetupStepInput, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + GenerationApis, + MediaReference, + WorkflowRun, + WorkflowRunStore, +} from '@/entities' +import { publishWorkflowRun } from '@/features/publish' +import { + createActionGenerationTask, + getCompleteAnimationFrameCountError, +} from './action-generation-task' +import { createCharacterTemplateTask } from './character-template-task' +import { + advanceCharacterSetupState, + appendActionState, + acceptUploadedCharacterTemplateState, + approveReviewState, + completeActionGenerationState, + confirmCandidateState, + createWorkflowRunState, + getActiveStep, + getCurrentRevision, + interruptWorkflowRunState, + recordActionGenerationTaskState, + restartWorkflowRunState, + requireActiveWorkflow, + updateCharacterSetupState, + type CreateWorkflowRunStateInput, +} from './workflow-state' + +/** 创建角色与给已有角色增加动作共用同一条运行状态机。 */ +export type CreateWorkflowControllerInput = CreateWorkflowRunStateInput + +export interface WorkflowController { + /** 创建并保存一条纯前端运行记录。 */ + create(input: CreateWorkflowControllerInput): WorkflowRun + + /** 按路由中的 runId 读取快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + + /** 按 Character 定位其唯一制作 Run;新增动作必须优先复用该 Run。 */ + getWorkflowByCharacter(characterId: string): WorkflowRun | null + + /** 在同一条已完成 Run 中追加新的动作生成与审核步骤。 */ + appendAction(runId: WorkflowRun['id']): WorkflowRun + + /** 订阅指定运行记录的本地变化。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + + /** 修改当前角色资料步骤,页面无需知道步骤内部 ID。 */ + updateCharacterSetup(runId: WorkflowRun['id'], input: CharacterSetupStepInput): WorkflowRun + + /** 采用已上传的角色母版,跳过图片生成与候选选择并激活动作生成。 */ + acceptUploadedCharacterTemplate( + runId: WorkflowRun['id'], + templateUrl: MediaReference, + ): WorkflowRun + + /** + * 推进一个步骤。当前纵切只实现角色资料到角色图生成; + * 后续步骤进入各自实现 PR 后再扩展,不在这里伪造完成。 + * spriteSize 为项目精灵图尺寸,角色图生成步骤需要传给后端做尺寸校验。 + */ + nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise + + /** 页面恢复时先读取任务终态;仍在运行时再恢复订阅。 */ + resume(runId: WorkflowRun['id']): Promise + + /** 只停止前端自动推进和任务订阅;后端当前没有取消任务能力。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun + + /** 确认候选选择,推进到下一个步骤。 */ + confirmCandidate(runId: WorkflowRun['id'], selectedImageUrl: string): WorkflowRun + + /** + * 采用已确认的角色母版,并统一完成 Character 落库、Run 绑定与动作任务提交。 + * Quick Start 和 Workflow Editor 都调用这个命令,页面不再各自复制业务编排。 + */ + startActionFromTemplate( + runId: WorkflowRun['id'], + templateImageUrl: string, + actionDescription?: string, + ): Promise + + /** 动作生成完成后写回结果,标记 action-generation 为 passed。 */ + completeActionGeneration( + runId: WorkflowRun['id'], + result: CompleteAnimationGenerationResult | { error: string }, + ): WorkflowRun + + /** 提交完整动作生成,并由 Controller 统一处理订阅和刷新恢复。 */ + startActionGeneration( + runId: WorkflowRun['id'], + input: CompleteAnimationGenerationInput, + ): Promise + + /** 审核通过后完成当前版本和整条运行;不在这里执行发布或下载。 */ + approveReview(runId: WorkflowRun['id']): WorkflowRun + + /** 审核当前动作并写入正式 Character;发布失败后允许用同一 Run 重试。 */ + approveAndPublish(runId: WorkflowRun['id']): Promise + + /** 动作生成任务提交后把任务 ID 落盘,供页面刷新后 resume 恢复轮询。 */ + recordActionGenerationTask(runId: WorkflowRun['id'], taskId: string): WorkflowRun + + /** 记录动作生成关联的角色与造型 ID,供导出到 Playtest 使用(刷新后可恢复)。 */ + recordCharacterRefs( + runId: WorkflowRun['id'], + refs: { characterId: string; outfitId: string }, + ): WorkflowRun + + /** 从当前执行线中一个已通过的节点创建新的本地 Revision。 */ + restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun +} + +export interface CreateWorkflowControllerOptions { + store: WorkflowRunStore + generationApis: GenerationApis + /** 创建角色流程需要该接口;只操作已有角色动作时可不配置。 */ + characterApis?: CharacterApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +/** + * Quick Start 与手动工作流共用的流程协调器。 + * + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, + * 不能在组件渲染期间重复创建。 + */ +export function createWorkflowController({ + store, + generationApis, + characterApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), +}: CreateWorkflowControllerOptions): WorkflowController { + const characterTemplateTask = createCharacterTemplateTask({ + store, + generationApis, + createSubmissionId: () => createId('submission'), + }) + const actionGenerationTask = createActionGenerationTask({ + store, + generationApis, + createSubmissionId: () => createId('submission'), + }) + + function getWorkflow(runId: WorkflowRun['id']) { + return store.get(runId) + } + + function getWorkflowByCharacter(characterId: string) { + return store.getByCharacter(characterId) + } + + function requireWorkflow(runId: WorkflowRun['id']) { + const run = getWorkflow(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run + } + + function save(run: WorkflowRun) { + store.save(run) + return run + } + + function create(input: CreateWorkflowControllerInput): WorkflowRun { + return save( + createWorkflowRunState(input, { + runId: createId('run'), + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + function appendAction(runId: WorkflowRun['id']): WorkflowRun { + return save(appendActionState(requireWorkflow(runId))) + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function updateCharacterSetup( + runId: WorkflowRun['id'], + input: CharacterSetupStepInput, + ): WorkflowRun { + return save(updateCharacterSetupState(requireWorkflow(runId), input)) + } + + function acceptUploadedCharacterTemplate( + runId: WorkflowRun['id'], + templateUrl: MediaReference, + ): WorkflowRun { + return save(acceptUploadedCharacterTemplateState(requireWorkflow(runId), templateUrl)) + } + + async function nextStep( + runId: WorkflowRun['id'], + spriteSize?: { width: number; height: number }, + ): Promise { + const run = requireActiveWorkflow(requireWorkflow(runId)) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + + if (activeStep.type === 'character-template') { + return characterTemplateTask.start(runId, { + revisionId: revision.id, + stepId: activeStep.id, + }) + } + if (activeStep.type !== 'character-setup') { + throw new Error(`步骤 ${activeStep.type} 尚未进入本轮实现`) + } + + if (!spriteSize) throw new Error('推进角色资料步骤需要项目精灵图尺寸') + + const transitioned = advanceCharacterSetupState(run, spriteSize) + save(transitioned.run) + return characterTemplateTask.start(runId, transitioned.target) + } + + function resume(runId: WorkflowRun['id']) { + const run = store.get(runId) + if (!run || run.status !== 'active') return Promise.resolve(run) + const step = getActiveStep(getCurrentRevision(run)) + return step?.type === 'action-generation' + ? actionGenerationTask.resume(runId) + : characterTemplateTask.resume(runId) + } + + function interrupt(runId: WorkflowRun['id']): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') return run + + characterTemplateTask.stop(runId) + actionGenerationTask.stop(runId) + const latest = requireWorkflow(runId) + if (latest.status !== 'active') return latest + return save(interruptWorkflowRunState(latest)) + } + + function confirmCandidate(runId: WorkflowRun['id'], selectedImageUrl: string): WorkflowRun { + return save(confirmCandidateState(requireWorkflow(runId), selectedImageUrl)) + } + + async function startActionFromTemplate( + runId: WorkflowRun['id'], + templateImageUrl: string, + actionDescription?: string, + ): Promise { + if (!characterApis) throw new Error('角色服务尚未配置,不能开始动作生成') + + const run = requireWorkflow(runId) + const initialState = getTemplateActionInputState(run, templateImageUrl) + let character = await characterApis.create({ + projectId: run.projectId, + description: 'Workflow auto-created character', + referenceImageUrl: templateImageUrl, + }) + + // 后端可能只创建 Character 顶层记录。这里补齐首个 Outfit,确保随后生成的动作 + // 有明确归属,并让 Run 保存可供刷新恢复和发布使用的稳定 ID。 + if (character.outfits.length === 0) { + character = await characterApis.update({ + ...character, + outfits: [ + { + id: `outfit-${character.id}-default`, + characterId: character.id, + name: '默认造型', + description: null, + previewUrl: templateImageUrl, + actions: [], + }, + ], + }) + } + + const outfitId = character.outfits[0]?.id + if (!outfitId) throw new Error('角色服务没有返回可用的造型 ID') + + // Character 创建是异步的。等待期间用户可能重启或推进了流程,因此提交任务前 + // 必须重新读取并核对输入状态,避免旧请求把结果写进新的执行线。 + const latest = requireWorkflow(runId) + const latestState = getTemplateActionInputState(latest, templateImageUrl) + if (latestState !== initialState) { + throw new Error('角色母版步骤已变更,不能继续提交动作生成') + } + const ready = + latestState === 'candidate-active' + ? save(confirmCandidateState(latest, templateImageUrl)) + : latest + const bound = save({ ...ready, characterId: character.id, outfitId }) + const prompt = actionDescription?.trim() + + try { + return await actionGenerationTask.start(runId, { + type: 'complete_animation', + projectId: bound.projectId, + characterId: character.id, + outfitId, + actionType: prompt ? 'custom' : 'idle', + firstFrameUrl: templateImageUrl, + prompt: prompt || null, + referenceMedia: [templateImageUrl as MediaReference], + }) + } catch (error) { + const failedRun = store.get(runId) + if (failedRun?.status === 'active') { + const activeStep = getActiveStep(getCurrentRevision(failedRun)) + if ( + activeStep?.type === 'action-generation' && + !activeStep.taskId && + !activeStep.submissionId + ) { + const message = + error instanceof Error && error.message.trim() ? error.message.trim() : '动作生成失败' + save(completeActionGenerationState(failedRun, { error: message })) + } + } + throw error + } + } + + function completeActionGeneration( + runId: WorkflowRun['id'], + result: CompleteAnimationGenerationResult | { error: string }, + ): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[completeActionGen] run not active:', run.status) + return run + } + const step = getActiveStep(getCurrentRevision(run)) + if (!step || step.status !== 'active') { + console.warn('[completeActionGen] step not active:', step?.type, step?.status) + return run + } + if ('error' in result) return save(completeActionGenerationState(run, result)) + + const frameCountError = getCompleteAnimationFrameCountError(result) + return save( + completeActionGenerationState(run, frameCountError ? { error: frameCountError } : result), + ) + } + + function startActionGeneration( + runId: WorkflowRun['id'], + input: CompleteAnimationGenerationInput, + ) { + return actionGenerationTask.start(runId, input) + } + + function approveReview(runId: WorkflowRun['id']): WorkflowRun { + return save(approveReviewState(requireWorkflow(runId))) + } + + async function approveAndPublish(runId: WorkflowRun['id']): Promise { + if (!characterApis) throw new Error('角色服务尚未配置,不能发布资产') + const run = requireWorkflow(runId) + const reviewStep = getCurrentRevision(run).steps.findLast((step) => step.type === 'review') + const approved = + run.status === 'active' && reviewStep?.status === 'active' + ? approveReview(runId) + : run.status === 'completed' && reviewStep?.status === 'passed' + ? run + : null + if (!approved) throw new Error('审核步骤尚未就绪,不能发布资产') + + // Run 先完成、资产后写入。若后端更新失败,第二次调用会复用 completed Run, + // 重新执行同一个确定性 actionId 的 upsert,不会重复审核或生成新动作。 + await publishWorkflowRun(characterApis, approved) + return approved + } + + function recordActionGenerationTask(runId: WorkflowRun['id'], taskId: string): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[recordActionTask] run not active:', run.status) + return run + } + return save(recordActionGenerationTaskState(run, taskId)) + } + + function recordCharacterRefs( + runId: WorkflowRun['id'], + refs: { characterId: string; outfitId: string }, + ): WorkflowRun { + const run = requireWorkflow(runId) + if (run.status !== 'active') { + console.warn('[recordCharacterRefs] run not active:', run.status) + return run + } + return save({ ...run, characterId: refs.characterId, outfitId: refs.outfitId }) + } + + function restart(runId: WorkflowRun['id'], stepId: string): WorkflowRun { + characterTemplateTask.stop(runId) + actionGenerationTask.stop(runId) + return save( + restartWorkflowRunState(requireWorkflow(runId), stepId, { + revisionId: createId('revision'), + createdAt: now(), + }), + ) + } + + return { + create, + getWorkflow, + getWorkflowByCharacter, + appendAction, + subscribe, + updateCharacterSetup, + acceptUploadedCharacterTemplate, + nextStep, + confirmCandidate, + startActionFromTemplate, + completeActionGeneration, + startActionGeneration, + approveReview, + approveAndPublish, + recordActionGenerationTask, + recordCharacterRefs, + restart, + resume, + interrupt, + } +} + +/** + * 区分“候选图刚被选择”和“上传母版已经被采用”两条入口。 + * 两条入口最终都必须停在同一个 action-generation 活动步骤,除此之外拒绝提交。 + */ +function getTemplateActionInputState( + run: WorkflowRun, + templateImageUrl: string, +): 'candidate-active' | 'uploaded-template' { + const revision = getCurrentRevision(run) + const candidate = revision.steps.find((step) => step.type === 'template-candidate') + const activeStep = getActiveStep(revision) + if (candidate?.status === 'active' && activeStep?.type === 'template-candidate') { + return 'candidate-active' + } + if ( + candidate?.status === 'passed' && + activeStep?.type === 'action-generation' && + hasSelectedTemplateUrl(candidate.output, templateImageUrl) + ) { + return 'uploaded-template' + } + throw new Error('当前流程状态不能开始动作生成') +} + +function hasSelectedTemplateUrl(output: unknown, templateImageUrl: string): boolean { + return ( + typeof output === 'object' && + output !== null && + 'selectedImageUrl' in output && + output.selectedImageUrl === templateImageUrl + ) +} + +function createRuntimeId(scope: 'run' | 'revision' | 'submission') { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + return `${scope}-${suffix}` +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce8792..fcb6978b 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,6 @@ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowStep, -} from '@/entities' - -/** 更新当前 Revision 中某个步骤的业务数据。 */ -export interface UpdateWorkflowStepInput { - stepId: WorkflowStep['id'] - data: unknown -} - -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] -} - -/** 把某次服务端调用的结果写回目标步骤。 */ -export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] - result: unknown -} - -/** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 - */ -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 -} +export { createWorkflowController } from './controller' +export type { + CreateWorkflowControllerInput, + CreateWorkflowControllerOptions, + WorkflowController, +} from './controller' diff --git a/frontend/src/features/workflow-controller/store-invariants.test.ts b/frontend/src/features/workflow-controller/store-invariants.test.ts new file mode 100644 index 00000000..cb6619d7 --- /dev/null +++ b/frontend/src/features/workflow-controller/store-invariants.test.ts @@ -0,0 +1,243 @@ +/** + * 状态机存储不变量穷举测试。 + * + * 每个状态转换点之后,run 必须满足 createWorkflowRunStore 的持久化校验 + * (刷新页面后能从 localStorage 恢复)。曾因 completeActionGeneration 后 + * review 未激活导致 active 步骤数为 0,刷新后 run 被校验过滤直接丢失。 + */ +import { describe, expect, it, vi } from 'vitest' + +import { + COMPLETE_ANIMATION_FRAME_COUNT, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, + type WorkflowRun, +} from '@/entities' +import { createWorkflowRunStore } from '@/entities/workflow-run/store' +import { createWorkflowController } from '.' + +/** 内存版 localStorage:save 后重建 store 即模拟刷新恢复。 */ +function createRefreshableStore() { + let snapshot: string | null = null + const storage = { + getItem: (key: string) => (key === 'windup.workflow-runs' ? snapshot : null), + setItem: (_key: string, value: string) => { + snapshot = value + }, + } + const store = createWorkflowRunStore({ storage }) + return { + store, + /** 模拟刷新:用同一份 storage 快照重建 store。 */ + refresh(): typeof store { + return createWorkflowRunStore({ storage }) + }, + } +} + +function createHarness() { + const { store, refresh } = createRefreshableStore() + const taskListeners = new Map void>() + + const generationApis: GenerationApis = { + create: vi.fn( + async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation, + ), + get: vi.fn(async () => { + throw new Error('not used') + }), + subscribe: vi.fn( + (_projectId: string, taskId: string, onEvent: (e: GenerationEvent) => void) => { + taskListeners.set(taskId, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + taskListeners.delete(taskId) + } + }, + ), + } + let idCounter = 0 + const controller = createWorkflowController({ + store, + generationApis, + createId: (scope) => `id-${scope}-${++idCounter}`, + now: () => '2026-07-31T12:00:00.000Z', + }) + + return { + store, + refresh, + taskListeners, + controller, + completeTemplateTask(taskId: string) { + const listener = taskListeners.get(taskId) + if (!listener) throw new Error(`missing listener ${taskId}`) + listener({ + taskId, + type: 'character_template', + status: 'completed', + error: null, + result: { type: 'character_template', images: [{ url: 'https://example.com/c.png' }] }, + }) + }, + } +} + +/** 断言 run 在刷新后仍可恢复(即通过 store 持久化校验)。 */ +function expectRefreshable( + harness: ReturnType, + runId: string, + label: string, +): WorkflowRun { + const restored = harness.refresh().get(runId) + expect(restored, `${label} 刷新后应可恢复`).not.toBeNull() + return restored! +} + +describe('store invariants across every state transition', () => { + it('an add_action run survives refresh before generation starts', () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '挥手', + characterId: 'character-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: [], + }) + + const restored = expectRefreshable(harness, created.id, '增加动作运行创建后') + expect(restored.characterId).toBe('character-1') + expect(restored.outfitId).toBe('outfit-1') + expect( + restored.revisions[0]?.steps.find((step) => step.type === 'action-generation')?.status, + ).toBe('active') + }) + + it('every step of the happy path survives a refresh', async () => { + const harness = createHarness() + + // 1. 创建(character-setup active) + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + const r1 = expectRefreshable(harness, created.id, '创建后') + expect(r1.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 2. 提交角色图任务(character-template active + submissionId) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + const r2 = expectRefreshable(harness, created.id, '角色图任务提交后') + const templateStep2 = r2.revisions[0]!.steps.find((s) => s.type === 'character-template')! + expect(templateStep2.status).toBe('active') + expect(r2.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 3. 角色图完成(character-template passed → template-candidate active) + harness.completeTemplateTask('task-1') + const r3 = expectRefreshable(harness, created.id, '角色图完成后') + expect(r3.revisions[0]!.steps.find((s) => s.type === 'template-candidate')!.status).toBe( + 'active', + ) + + // 4. 确认候选(action-generation active) + harness.controller.confirmCandidate(created.id, 'https://example.com/c.png') + const r4 = expectRefreshable(harness, created.id, '确认候选后') + expect(r4.revisions[0]!.steps.find((s) => s.type === 'action-generation')!.status).toBe( + 'active', + ) + expect(r4.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + + // 5. 动作生成完成(action-generation passed → review active) + harness.controller.recordActionGenerationTask(created.id, 'task-action-1') + harness.controller.completeActionGeneration(created.id, { + type: 'complete_animation', + actionType: 'idle', + frames: Array.from({ length: COMPLETE_ANIMATION_FRAME_COUNT }, (_, index) => ({ + url: `https://example.com/f-${index}.png`, + durationMs: 125, + })), + }) + const r5 = expectRefreshable(harness, created.id, '动作完成后') + const reviewStep = r5.revisions[0]!.steps.find((s) => s.type === 'review')! + expect(reviewStep.status).toBe('active') + expect(r5.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) + + it('a failed action generation survives a refresh and stays failed', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + harness.completeTemplateTask('task-1') + harness.controller.confirmCandidate(created.id, 'https://example.com/c.png') + + harness.controller.completeActionGeneration(created.id, { error: '生成服务超时' }) + + const r = expectRefreshable(harness, created.id, '动作失败后') + expect(r.status).toBe('failed') + expect(r.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(0) + expect(r.revisions[0]!.steps.find((s) => s.type === 'action-generation')!.status).toBe('failed') + }) + + it('an interrupted run survives a refresh with exactly one active step', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + harness.controller.interrupt(created.id) + + const r = expectRefreshable(harness, created.id, '中断后') + expect(r.status).toBe('interrupted') + expect(r.revisions[0]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) + + it('a restart from a passed step survives a refresh', async () => { + const harness = createHarness() + const created = harness.controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + await harness.controller.nextStep(created.id, { width: 256, height: 256 }) + harness.completeTemplateTask('task-1') + const after = harness.controller.getWorkflow(created.id)! + const revision = after.revisions[0]! + const setupStep = revision.steps.find((s) => s.type === 'character-setup')! + + harness.controller.restart(created.id, setupStep.id) + + const r = expectRefreshable(harness, created.id, '重开后') + expect(r.revisions).toHaveLength(2) + expect(r.revisions[0]!.status).toBe('abandoned') + expect(r.revisions[1]!.steps.filter((s) => s.status === 'active')).toHaveLength(1) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-run.integration.test.ts b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts new file mode 100644 index 00000000..62d8f414 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-run.integration.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, +} from '@/entities' +import { createWorkflowController } from '.' + +describe('WorkflowRun first vertical slice', () => { + it('runs character setup through a completed character-template task', async () => { + const store = createWorkflowRunStore({ storage: null }) + const taskChannel: { listener?: (event: GenerationEvent) => void } = {} + + const createGeneration: GenerationApis['create'] = async ( + input: T, + ) => + ({ + id: 'task-character-template-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation + + const generationApis: GenerationApis = { + create: vi.fn(createGeneration), + get: vi.fn(async () => { + throw new Error('not used in this slice') + }), + subscribe: vi.fn((_projectId, taskId, onEvent) => { + taskChannel.listener = onEvent + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + error: null, + result: null, + }) + return () => { + delete taskChannel.listener + } + }), + } + const ids = ['run-1', 'revision-1'] + const controller = createWorkflowController({ + store, + generationApis, + createId: () => ids.shift() ?? 'unexpected-id', + now: () => '2026-07-30T12:00:00.000Z', + }) + + const created = await controller.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: '像素骑士', + }) + + await controller.nextStep(created.id, { width: 64, height: 64 }) + + const inFlight = store.get(created.id) + expect( + inFlight?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'active', + taskId: 'task-character-template-1', + }) + + const taskListener = taskChannel.listener + if (!taskListener) throw new Error('expected the task subscription to be active') + taskListener({ + taskId: 'task-character-template-1', + type: 'character_template', + status: 'completed', + error: null, + result: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + await Promise.resolve() + + const completed = store.get(created.id) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'character-template'), + ).toMatchObject({ + status: 'passed', + taskId: null, + output: { + type: 'character_template', + images: [{ url: 'https://example.com/knight.png' }], + }, + }) + expect( + completed?.revisions[0].steps.find((step) => step.type === 'template-candidate'), + ).toMatchObject({ status: 'active' }) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.test.ts b/frontend/src/features/workflow-controller/workflow-state.test.ts new file mode 100644 index 00000000..504b32e1 --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from 'vitest' + +import type { MediaReference } from '@/entities' + +import { + acceptUploadedCharacterTemplateState, + advanceCharacterSetupState, + appendActionState, + approveReviewState, + beginActionGenerationState, + completeActionGenerationState, + createWorkflowRunState, + restartWorkflowRunState, + updateCharacterSetupState, +} from './workflow-state' + +const CREATED_AT = '2026-07-31T02:40:00.000Z' + +function createRun() { + return createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' pixel knight ', + }, + { + runId: 'run-1', + revisionId: 'revision-1', + createdAt: CREATED_AT, + }, + ) +} + +describe('workflow state transitions', () => { + it('creates the fixed five-step workflow and keeps export outside the step sequence', () => { + const run = createRun() + + expect(run).toMatchObject({ + id: 'run-1', + projectId: 'project-1', + status: 'active', + prompt: 'pixel knight', + currentRevisionId: 'revision-1', + }) + expect(run.revisions[0]?.createdAt).toBe(CREATED_AT) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'active' }, + { type: 'character-template', status: 'locked' }, + { type: 'template-candidate', status: 'locked' }, + { type: 'action-generation', status: 'locked' }, + { type: 'review', status: 'locked' }, + ]) + expect(run.revisions[0]?.exportStatus).toBe('not_exported') + expect(run.revisions[0]?.steps[0]?.input).toEqual({ + description: 'pixel knight', + referenceMedia: [], + }) + }) + + it('starts add_action directly at action generation for the existing outfit', () => { + const run = createWorkflowRunState( + { + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '挥手打招呼', + characterId: 'character-1', + outfitId: 'outfit-1', + characterTemplateUrl: 'https://example.com/template.png', + baseFrameUrls: [], + }, + { + runId: 'run-action-1', + revisionId: 'revision-action-1', + createdAt: CREATED_AT, + }, + ) + + expect(run).toMatchObject({ + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + prompt: '挥手打招呼', + }) + expect(run.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'passed' }, + { type: 'character-template', status: 'passed' }, + { type: 'template-candidate', status: 'passed' }, + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + }) + + it('normalizes character setup input before storing it', () => { + const updated = updateCharacterSetupState(createRun(), { + description: ' revised knight ', + referenceMedia: [], + }) + + expect(updated.revisions[0]?.steps[0]?.input).toEqual({ + description: 'revised knight', + referenceMedia: [], + }) + }) + + it('accepts an uploaded character template without fabricating an image generation task', () => { + const accepted = acceptUploadedCharacterTemplateState( + createRun(), + 'https://cdn.example.com/uploaded-character.png' as MediaReference, + ) + const revision = accepted.revisions[0]! + + expect(revision.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'passed' }, + { type: 'character-template', status: 'passed' }, + { type: 'template-candidate', status: 'passed' }, + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + expect(revision.steps[0]?.input).toEqual({ + description: '使用上传角色母版', + referenceMedia: ['https://cdn.example.com/uploaded-character.png'], + }) + expect(revision.steps[1]).toMatchObject({ + type: 'character-template', + taskId: null, + submissionId: null, + input: null, + output: { + type: 'character_template', + images: [{ url: 'https://cdn.example.com/uploaded-character.png' }], + }, + }) + expect(revision.steps[2]?.output).toEqual({ + selectedImageUrl: 'https://cdn.example.com/uploaded-character.png', + }) + expect(revision.generationStatus).toBe('not_started') + expect(revision.steps.filter((step) => step.status === 'active')).toHaveLength(1) + }) + + it('activates character-template with its generation input snapshot', () => { + const run = updateCharacterSetupState(createRun(), { + description: 'revised knight', + referenceMedia: [], + }) + + const transitioned = advanceCharacterSetupState(run, { width: 64, height: 64 }) + + expect(transitioned.target).toEqual({ + revisionId: 'revision-1', + stepId: 'revision-1:character-template', + }) + expect(transitioned.run.revisions[0]?.steps.slice(0, 3)).toMatchObject([ + { type: 'character-setup', status: 'passed' }, + { + type: 'character-template', + status: 'active', + input: { + type: 'character_template', + projectId: 'project-1', + prompt: 'revised knight', + referenceMedia: [], + spriteWidth: 64, + spriteHeight: 64, + }, + }, + { type: 'template-candidate', status: 'locked' }, + ]) + }) + + it('creates a new revision from a passed stage without retaining downstream outputs', () => { + const prepared = advanceCharacterSetupState(createRun(), { width: 64, height: 64 }).run + const sourceRevision = prepared.revisions[0]! + const run = { + ...prepared, + revisions: [ + { + ...sourceRevision, + steps: sourceRevision.steps.map((step) => + step.type === 'character-template' + ? { ...step, status: 'passed' as const } + : step.type === 'template-candidate' + ? { ...step, status: 'active' as const } + : step, + ), + }, + ], + } + + const restarted = restartWorkflowRunState(run, 'revision-1:character-template', { + revisionId: 'revision-2', + createdAt: '2026-07-31T03:00:00.000Z', + }) + + expect(restarted).toMatchObject({ + status: 'active', + currentRevisionId: 'revision-2', + }) + expect(restarted.revisions).toHaveLength(2) + expect(restarted.revisions[0]?.status).toBe('abandoned') + expect(restarted.revisions[1]).toMatchObject({ + id: 'revision-2', + basedOnRevisionId: 'revision-1', + restartStepId: 'revision-1:character-template', + }) + expect( + restarted.revisions[1]?.steps.map(({ type, status, referenceStepIds }) => ({ + type, + status, + referenceStepIds, + })), + ).toEqual([ + { + type: 'character-setup', + status: 'passed', + referenceStepIds: ['revision-1:character-setup'], + }, + { + type: 'character-template', + status: 'active', + referenceStepIds: ['revision-1:character-template'], + }, + { type: 'template-candidate', status: 'locked', referenceStepIds: [] }, + { type: 'action-generation', status: 'locked', referenceStepIds: [] }, + { type: 'review', status: 'locked', referenceStepIds: [] }, + ]) + }) + + it('rejects a restart from a stage that has not passed', () => { + expect(() => + restartWorkflowRunState(createRun(), 'revision-1:character-template', { + revisionId: 'revision-2', + createdAt: '2026-07-31T03:00:00.000Z', + }), + ).toThrow('只能从已通过的步骤重新开始') + }) + + it('completes the revision and run when the active review is approved', () => { + const run = createRun() + const readyForReview = { + ...run, + revisions: run.revisions.map((revision) => ({ + ...revision, + generationStatus: 'completed' as const, + steps: revision.steps.map((step) => ({ + ...step, + status: step.type === 'review' ? ('active' as const) : ('passed' as const), + })), + })), + } + + const completed = approveReviewState(readyForReview) + + expect(completed.status).toBe('completed') + expect(completed.revisions[0]?.status).toBe('completed') + expect(completed.revisions[0]?.steps.every((step) => step.status === 'passed')).toBe(true) + }) + + it('reopens the same completed run and appends another action pair', () => { + const readyForReview = { + ...createRun(), + characterId: 'character-1', + outfitId: 'outfit-1', + revisions: createRun().revisions.map((revision) => ({ + ...revision, + generationStatus: 'completed' as const, + steps: revision.steps.map((step) => ({ + ...step, + status: step.type === 'review' ? ('active' as const) : ('passed' as const), + })), + })), + } + const completed = approveReviewState(readyForReview) + + const appended = appendActionState(completed) + + expect(appended.id).toBe('run-1') + expect(appended.currentRevisionId).toBe('revision-1') + expect(appended.status).toBe('active') + expect(appended.revisions[0]?.status).toBe('active') + expect(appended.revisions[0]?.steps.map(({ type, status }) => ({ type, status }))).toEqual([ + { type: 'character-setup', status: 'passed' }, + { type: 'character-template', status: 'passed' }, + { type: 'template-candidate', status: 'passed' }, + { type: 'action-generation', status: 'passed' }, + { type: 'review', status: 'passed' }, + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + expect(new Set(appended.revisions[0]?.steps.map((step) => step.id)).size).toBe(7) + }) + + it('completes the newly appended action without overwriting the previous action', () => { + const base = createRun() + const completed = { + ...base, + characterId: 'character-1', + outfitId: 'outfit-1', + status: 'completed' as const, + revisions: base.revisions.map((revision) => ({ + ...revision, + status: 'completed' as const, + generationStatus: 'completed' as const, + steps: revision.steps.map((step) => + step.type === 'action-generation' + ? { + ...step, + status: 'passed' as const, + output: { + type: 'complete_animation' as const, + actionType: 'idle' as const, + frames: [{ url: 'idle.png', durationMs: null }], + }, + } + : { ...step, status: 'passed' as const }, + ), + })), + } + const appended = appendActionState(completed) + const input = { + type: 'complete_animation' as const, + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionType: 'custom' as const, + firstFrameUrl: 'template.png', + prompt: '挥手', + referenceMedia: ['template.png' as MediaReference], + } + + const submitting = beginActionGenerationState(appended, input, 'submission-2') + const generated = completeActionGenerationState(submitting, { + type: 'complete_animation', + actionType: 'custom', + frames: [{ url: 'wave.png', durationMs: null }], + }) + const actions = generated.revisions[0]!.steps.filter( + (step) => step.type === 'action-generation', + ) + const reviews = generated.revisions[0]!.steps.filter((step) => step.type === 'review') + + expect(actions).toHaveLength(2) + expect(actions[0]).toMatchObject({ status: 'passed', output: { actionType: 'idle' } }) + expect(actions[1]).toMatchObject({ status: 'passed', output: { actionType: 'custom' } }) + expect(reviews.map((step) => step.status)).toEqual(['passed', 'active']) + }) + + it('keeps repeated action step ids unique when restarting the second action', () => { + const source = createRun() + const firstCompleted = { + ...source, + characterId: 'character-1', + outfitId: 'outfit-1', + status: 'completed' as const, + revisions: source.revisions.map((revision) => ({ + ...revision, + status: 'completed' as const, + generationStatus: 'completed' as const, + steps: revision.steps.map((step) => ({ ...step, status: 'passed' as const })), + })), + } + const appended = appendActionState(firstCompleted) + const secondCompleted = { + ...appended, + status: 'completed' as const, + revisions: appended.revisions.map((revision) => ({ + ...revision, + status: 'completed' as const, + steps: revision.steps.map((step) => ({ ...step, status: 'passed' as const })), + })), + } + + const restarted = restartWorkflowRunState(secondCompleted, 'revision-1:action-generation:2', { + revisionId: 'revision-2', + createdAt: '2026-08-06T00:00:00.000Z', + }) + const ids = restarted.revisions.at(-1)!.steps.map((step) => step.id) + + expect(new Set(ids).size).toBe(ids.length) + expect(ids.slice(-2)).toEqual(['revision-2:action-generation:2', 'revision-2:review:2']) + }) + + it('keeps later actions only in history when restarting an earlier action', () => { + const source = createRun() + const firstCompleted = { + ...source, + characterId: 'character-1', + outfitId: 'outfit-1', + status: 'completed' as const, + revisions: source.revisions.map((revision) => ({ + ...revision, + status: 'completed' as const, + steps: revision.steps.map((step) => ({ ...step, status: 'passed' as const })), + })), + } + const appended = appendActionState(firstCompleted) + const withTwoCompletedActions = { + ...appended, + status: 'completed' as const, + revisions: appended.revisions.map((revision) => ({ + ...revision, + status: 'completed' as const, + steps: revision.steps.map((step) => ({ ...step, status: 'passed' as const })), + })), + } + + const restarted = restartWorkflowRunState( + withTwoCompletedActions, + 'revision-1:action-generation', + { revisionId: 'revision-2', createdAt: '2026-08-06T00:00:00.000Z' }, + ) + + expect(restarted.revisions[0]?.steps).toHaveLength(7) + expect(restarted.revisions[1]?.steps).toHaveLength(5) + expect(restarted.revisions[1]?.steps.slice(-2)).toMatchObject([ + { type: 'action-generation', status: 'active' }, + { type: 'review', status: 'locked' }, + ]) + }) +}) diff --git a/frontend/src/features/workflow-controller/workflow-state.ts b/frontend/src/features/workflow-controller/workflow-state.ts new file mode 100644 index 00000000..8d4b9dbc --- /dev/null +++ b/frontend/src/features/workflow-controller/workflow-state.ts @@ -0,0 +1,657 @@ +import { + WORKFLOW_STEP_ORDER, + type CharacterSetupStepInput, + type CharacterTemplateGenerationInput, + type CompleteAnimationGenerationInput, + type CompleteAnimationGenerationResult, + type CreateWorkflowRunInput, + type MediaReference, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepStatus, + type WorkflowStepType, +} from '@/entities' + +export type CreateWorkflowRunStateInput = CreateWorkflowRunInput + +export interface CreateWorkflowRunStateOptions { + runId: WorkflowRun['id'] + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export interface WorkflowStepTarget { + revisionId: WorkflowRevision['id'] + stepId: WorkflowStep['id'] +} + +export interface RestartWorkflowRunStateOptions { + revisionId: WorkflowRevision['id'] + createdAt: string +} + +export function createWorkflowRunState( + input: CreateWorkflowRunStateInput, + { runId, revisionId, createdAt }: CreateWorkflowRunStateOptions, +): WorkflowRun { + const prompt = input.prompt?.trim() || null + const steps = createInitialSteps(input, revisionId, prompt) + + return { + id: runId, + projectId: input.projectId, + characterId: input.purpose === 'add_action' ? input.characterId : null, + outfitId: input.purpose === 'add_action' ? input.outfitId : null, + purpose: input.purpose, + driver: input.driver, + status: 'active', + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + }, + ], + prompt, + } +} + +function createInitialSteps( + input: CreateWorkflowRunStateInput, + revisionId: string, + prompt: string | null, +): WorkflowStep[] { + const steps = WORKFLOW_STEP_ORDER.map((type, index) => + createInitialStep(type, revisionId, index, prompt), + ) + if (input.purpose === 'create_character') return steps + + return steps.map((step) => { + if (step.type === 'character-setup') { + return { + ...step, + status: 'passed' as const, + input: { + description: prompt ?? '为已有角色添加动作', + referenceMedia: [], + }, + } + } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed' as const, + output: { + type: 'character_template' as const, + images: [{ url: input.characterTemplateUrl }], + }, + } + } + if (step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl: input.characterTemplateUrl }, + } + } + if (step.type === 'action-generation') { + return { ...step, status: 'active' as const } + } + return step + }) +} + +export function getCurrentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + return revision +} + +export function getActiveStep(revision: WorkflowRevision): WorkflowStep | null { + return revision.steps.find((step) => step.status === 'active') ?? null +} + +export function requireActiveWorkflow(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + return run +} + +export function replaceWorkflowStep( + run: WorkflowRun, + revisionId: WorkflowRevision['id'], + stepId: WorkflowStep['id'], + update: (step: WorkflowStep) => WorkflowStep, + revisionUpdate?: (revision: WorkflowRevision) => WorkflowRevision, +): WorkflowRun { + return { + ...run, + revisions: run.revisions.map((revision) => { + if (revision.id !== revisionId) return revision + const nextRevision = { + ...revision, + steps: revision.steps.map((step) => (step.id === stepId ? update(step) : step)), + } + return revisionUpdate ? revisionUpdate(nextRevision) : nextRevision + }), + } +} + +export function updateCharacterSetupState( + workflow: WorkflowRun, + input: CharacterSetupStepInput, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const step = revision.steps.find((item) => item.type === 'character-setup') + if (!step || step.type !== 'character-setup' || step.status !== 'active') { + throw new Error('当前只能更新处于 active 状态的角色资料步骤') + } + + const description = input.description.trim() + if (!description) throw new Error('角色描述不能为空') + + return replaceWorkflowStep(run, revision.id, step.id, (current) => { + if (current.type !== 'character-setup') return current + return { + ...current, + input: { + description, + referenceMedia: [...input.referenceMedia], + }, + } + }) +} + +/** + * 采用用户已上传的角色母版,明确跳过角色图片生成和候选选择。 + * + * 该转换不创建 Generation 任务;上传图片只作为已提供的输入和已确认母版记录。 + * 动作任务仍由随后的 startActionGeneration 正常提交。 + */ +export function acceptUploadedCharacterTemplateState( + workflow: WorkflowRun, + templateUrl: MediaReference, +): WorkflowRun { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep || activeStep.type !== 'character-setup') { + throw new Error('当前只能在角色资料步骤采用上传母版') + } + + const normalizedUrl = String(templateUrl).trim() + if (!normalizedUrl) throw new Error('上传角色母版引用不能为空') + const mediaReference = normalizedUrl as MediaReference + + return { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((step) => { + if (step.type === 'character-setup') { + return { + ...step, + status: 'passed' as const, + input: { + description: '使用上传角色母版', + referenceMedia: [mediaReference], + }, + } + } + if (step.type === 'character-template') { + return { + ...step, + status: 'passed' as const, + input: null, + output: { + type: 'character_template' as const, + images: [{ url: normalizedUrl }], + }, + } + } + if (step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl: normalizedUrl }, + } + } + if (step.type === 'action-generation') { + return { ...step, status: 'active' as const } + } + return step + }), + } + }), + } +} + +export function advanceCharacterSetupState( + workflow: WorkflowRun, + spriteSize: { width: number; height: number }, +): { + run: WorkflowRun + target: WorkflowStepTarget +} { + const run = requireActiveWorkflow(workflow) + const revision = getCurrentRevision(run) + const activeStep = getActiveStep(revision) + if (!activeStep) throw new Error('当前 WorkflowRun 没有 active 步骤') + if (activeStep.type !== 'character-setup') { + throw new Error(`当前步骤不是角色资料:${activeStep.type}`) + } + if (!activeStep.input) throw new Error('请先填写角色资料') + + const templateStep = revision.steps.find((step) => step.type === 'character-template') + if (!templateStep) throw new Error('WorkflowRun 缺少 character-template 步骤') + + const generationInput: CharacterTemplateGenerationInput = { + type: 'character_template', + projectId: run.projectId, + prompt: activeStep.input.description, + referenceMedia: activeStep.input.referenceMedia, + spriteWidth: spriteSize.width, + spriteHeight: spriteSize.height, + } + + return { + run: { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + generationStatus: 'in_progress' as const, + steps: item.steps.map((step) => { + if (step.id === activeStep.id) return { ...step, status: 'passed' as const } + if (step.id !== templateStep.id || step.type !== 'character-template') return step + return { + ...step, + status: 'active' as const, + input: generationInput, + } + }), + } + }), + }, + target: { + revisionId: revision.id, + stepId: templateStep.id, + }, + } +} + +/** + * 确认候选选择:标记 template-candidate 为 passed,激活下一个步骤。 + */ +export function confirmCandidateState(run: WorkflowRun, selectedImageUrl: string): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可推进:${run.status}`) + const revision = getCurrentRevision(run) + const candidateStep = revision.steps.find((step) => step.type === 'template-candidate') + if (!candidateStep || candidateStep.status !== 'active') { + throw new Error('当前只能确认处于 active 状态的候选步骤') + } + + const nextIndex = WORKFLOW_STEP_ORDER.indexOf('template-candidate') + 1 + const nextType = WORKFLOW_STEP_ORDER[nextIndex] + + return { + ...run, + revisions: run.revisions.map((item) => { + if (item.id !== revision.id) return item + return { + ...item, + steps: item.steps.map((step) => { + if (step.id === candidateStep.id && step.type === 'template-candidate') { + return { + ...step, + status: 'passed' as const, + output: { selectedImageUrl }, + } + } + if (nextType && step.type === nextType) { + return { ...step, status: 'active' as const } + } + return step + }), + } + }), + } +} + +/** + * 动作生成完成:与 confirmCandidateState 对称。 + * + * 成功时把 action-generation 标记 passed 并激活 review 步骤;失败时标记 failed + * 并把整个 run 置为 failed。两个方向都保证「active 状态的 run 恰好有一个 active + * 步骤」,让刷新后的存储校验能够恢复这条运行记录。 + */ +export function completeActionGenerationState( + run: WorkflowRun, + result: CompleteAnimationGenerationResult | { error: string }, +): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可完成动作生成:${run.status}`) + const revision = getCurrentRevision(run) + const actionStep = getActiveStep(revision) + if (!actionStep || actionStep.type !== 'action-generation') { + throw new Error('当前只能完成处于 active 状态的动作生成步骤') + } + + const failed = result !== null && typeof result === 'object' && 'error' in result + const actionIndex = revision.steps.findIndex((step) => step.id === actionStep.id) + const reviewStep = revision.steps[actionIndex + 1] + if (!reviewStep || reviewStep.type !== 'review') { + throw new Error('动作生成步骤后缺少配对的审核步骤') + } + + const updated = replaceWorkflowStep( + run, + revision.id, + actionStep.id, + (current) => { + if (current.type !== 'action-generation') return current + return { + ...current, + status: failed ? ('failed' as const) : ('passed' as const), + output: failed ? null : result, + error: failed ? String((result as { error: string }).error) : null, + // 任务已终态,解除任务 ID 关联(存储校验要求终态步骤不持有任务 ID) + taskId: null, + submissionId: null, + } + }, + (current) => ({ + ...current, + status: failed ? ('failed' as const) : current.status, + generationStatus: failed ? ('failed' as const) : ('completed' as const), + steps: current.steps.map((step) => { + if (failed || !reviewStep || step.id !== reviewStep.id || step.type !== 'review') { + return step + } + return { ...step, status: 'active' as const } + }), + }), + ) + + return failed ? { ...updated, status: 'failed' as const } : updated +} + +/** 审核通过后结束当前版本和整条运行;发布与下载仍由后续独立功能处理。 */ +export function approveReviewState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'active') throw new Error(`WorkflowRun 当前不可审核:${run.status}`) + const revision = getCurrentRevision(run) + const reviewStep = getActiveStep(revision) + if (!reviewStep || reviewStep.type !== 'review') { + throw new Error('当前只能通过处于 active 状态的审核步骤') + } + + return { + ...run, + status: 'completed', + revisions: run.revisions.map((item) => + item.id === revision.id + ? { + ...item, + status: 'completed', + steps: item.steps.map((step) => + step.id === reviewStep.id + ? { ...step, status: 'passed' as const, error: null } + : step, + ), + } + : item, + ), + } +} + +/** + * 在已完成的角色 Run 当前执行线上追加一组动作步骤。 + * + * 追加动作不是重做,不创建 Revision,也不复制角色前三步;旧动作与审核结果继续保持 + * passed,新动作成为唯一 active 步骤。这样一个 Character 始终由同一条 WorkflowRun + * 串起全部动作,同时 Revision 仍只表达“从历史步骤重新执行”。 + */ +export function appendActionState(run: WorkflowRun): WorkflowRun { + if (run.status !== 'completed') throw new Error('只能给已完成的 WorkflowRun 追加动作') + if (!run.characterId || !run.outfitId) throw new Error('WorkflowRun 尚未绑定角色与造型') + const revision = getCurrentRevision(run) + if (revision.status !== 'completed') throw new Error('当前 Revision 尚未完成') + + const actionNumber = revision.steps.filter((step) => step.type === 'action-generation').length + 1 + const actionStep = { + ...createInitialStep('action-generation', revision.id, revision.steps.length, null), + id: `${revision.id}:action-generation:${actionNumber}`, + status: 'active' as const, + } + const reviewStep = { + ...createInitialStep('review', revision.id, revision.steps.length + 1, null), + id: `${revision.id}:review:${actionNumber}`, + status: 'locked' as const, + } + + return { + ...run, + status: 'active', + revisions: run.revisions.map((item) => + item.id === revision.id + ? { + ...item, + status: 'active' as const, + generationStatus: 'not_started' as const, + exportStatus: 'not_exported' as const, + steps: [...item.steps, actionStep, reviewStep], + } + : item, + ), + } +} + +/** + * 记录动作生成任务 ID:步骤保持 active,只是把 taskId 落盘,供刷新后 resume 恢复。 + */ +export function beginActionGenerationState( + run: WorkflowRun, + input: CompleteAnimationGenerationInput, + submissionId: string, +): WorkflowRun { + const revision = getCurrentRevision(requireActiveWorkflow(run)) + const actionStep = getActiveStep(revision) + if (!actionStep || actionStep.type !== 'action-generation' || actionStep.taskId) { + throw new Error('当前动作生成步骤不可重复提交') + } + return replaceWorkflowStep(run, revision.id, actionStep.id, (current) => { + if (current.type !== 'action-generation') return current + return { ...current, input, submissionId, error: null } + }) +} + +export function recordActionGenerationTaskState( + run: WorkflowRun, + taskId: string, + input?: CompleteAnimationGenerationInput, +): WorkflowRun { + if (run.status !== 'active' && run.status !== 'interrupted') { + throw new Error(`WorkflowRun 当前不可记录任务:${run.status}`) + } + const revision = getCurrentRevision(run) + const actionStep = getActiveStep(revision) + if (!actionStep || actionStep.type !== 'action-generation') { + throw new Error('当前只能为 active 状态的动作生成步骤记录任务') + } + return replaceWorkflowStep(run, revision.id, actionStep.id, (current) => { + if (current.type !== 'action-generation') return current + return { ...current, taskId, input: input ?? current.input, submissionId: null } + }) +} + +export function interruptWorkflowRunState(run: WorkflowRun): WorkflowRun { + return run.status === 'active' ? { ...run, status: 'interrupted' } : run +} + +/** + * 从已通过节点开启新的执行线。 + * + * 旧 Revision 保留为只读历史;重开点之前的结果作为新线参考,重开点及之后的结果 + * 不会进入新线。前三个角色步骤只出现一次,后面可以有多组动作/审核步骤;当前 + * 重做目标的配对步骤会清空并重新锁定,更晚的动作只保留在旧 Revision 历史中。 + */ +export function restartWorkflowRunState( + run: WorkflowRun, + restartStepId: WorkflowStep['id'], + { revisionId, createdAt }: RestartWorkflowRunStateOptions, +): WorkflowRun { + const sourceRevision = getCurrentRevision(run) + const restartIndex = sourceRevision.steps.findIndex((step) => step.id === restartStepId) + const restartStep = sourceRevision.steps[restartIndex] + if (!restartStep || restartStep.status !== 'passed') { + throw new Error('只能从已通过的步骤重新开始') + } + + // 后续旧动作仍完整保存在 sourceRevision;新执行线若继续携带它们,会在较早审核 + // 完成后留下永远无法激活的 locked 节点。角色阶段重开保留基础五步,动作阶段重开 + // 则保留到该动作配对的审核为止。 + const retainedStepCount = + restartIndex < 3 + ? WORKFLOW_STEP_ORDER.length + : restartStep.type === 'action-generation' + ? restartIndex + 2 + : restartIndex + 1 + const steps = sourceRevision.steps.slice(0, retainedStepCount).map((step, index) => { + if (index < restartIndex) return copyReferenceStep(step, revisionId, index) + if (index === restartIndex) return createRestartStep(step, revisionId, index) + + return lockFreshStep(step.type, revisionId, index, run.prompt) + }) + + const revision: WorkflowRevision = { + id: revisionId, + basedOnRevisionId: sourceRevision.id, + restartStepId: restartStep.id, + status: 'active', + steps, + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt, + } + + return { + ...run, + status: 'active', + currentRevisionId: revision.id, + revisions: [ + ...run.revisions.map((item) => + item.id === sourceRevision.id ? { ...item, status: 'abandoned' as const } : item, + ), + revision, + ], + } +} + +function copyReferenceStep( + step: WorkflowStep, + revisionId: WorkflowRevision['id'], + index: number, +): WorkflowStep { + const source = structuredClone(step) + return { + ...source, + id: createStepId(revisionId, source.type, index), + status: 'passed', + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [step.id], + } +} + +function createRestartStep( + step: WorkflowStep, + revisionId: WorkflowRevision['id'], + index: number, +): WorkflowStep { + const source = structuredClone(step) + return { + ...source, + id: createStepId(revisionId, source.type, index), + status: 'active', + taskId: null, + submissionId: null, + error: null, + output: null, + referenceStepIds: [step.id], + } as WorkflowStep +} + +function lockFreshStep( + type: WorkflowStepType, + revisionId: WorkflowRevision['id'], + index: number, + prompt: string | null, +): WorkflowStep { + return { + ...createInitialStep(type, revisionId, index, prompt), + status: 'locked', + referenceStepIds: [], + } +} + +function createInitialStep( + type: WorkflowStepType, + revisionId: string, + index: number, + prompt: string | null, +): WorkflowStep { + const status: WorkflowStepStatus = index === 0 ? 'active' : 'locked' + const base: { + id: string + status: WorkflowStepStatus + taskId: null + submissionId: null + error: null + referenceStepIds: string[] + } = { + id: createStepId(revisionId, type, index), + status, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + } + + if (type === 'character-setup') { + return { + ...base, + type, + input: prompt ? { description: prompt, referenceMedia: [] } : null, + output: null, + } + } + if (type === 'character-template') { + return { + ...base, + type, + input: null, + output: null, + } + } + return { ...base, type, input: null, output: null } as WorkflowStep +} + +/** + * 首组五步沿用旧 ID,兼容现有 URL 与持久数据;第二组起增加动作序号,保证同类型 + * 节点在同一 Revision 内仍然唯一。 + */ +function createStepId(revisionId: string, type: WorkflowStepType, index: number): string { + if (index < WORKFLOW_STEP_ORDER.length) return `${revisionId}:${type}` + const actionNumber = Math.floor((index - 3) / 2) + 1 + return `${revisionId}:${type}:${actionNumber}` +}