From 0e7e107f411a917e03b24fc3f032ed7103726639 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:49:17 +0800 Subject: [PATCH 01/27] feat(workflow-run): add versioned history store --- frontend-architecture-v3.md | 16 +- frontend/README.md | 5 +- frontend/src/entities/index.ts | 4 +- .../src/entities/workflow-run/constants.ts | 19 ++ frontend/src/entities/workflow-run/index.ts | 128 +++------ .../src/entities/workflow-run/store.test.ts | 208 ++++++++++++++ frontend/src/entities/workflow-run/store.ts | 264 ++++++++++++++++++ 7 files changed, 538 insertions(+), 106 deletions(-) create mode 100644 frontend/src/entities/workflow-run/constants.ts create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/entities/workflow-run/store.ts diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index ed137eca..bfaedc16 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 +本文记录当前前端的模块划分与依赖规则。功能实现按可独立审核的模块逐步提交。 --- @@ -84,15 +84,15 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 --- -## 5. 本次不包含 +## 5. 当前实现范围 -- 任何实现代码(真实请求、假数据、组件内部逻辑) -- 测试文件 -- 图片上传模块(体量太小,本次不单独体现) -- 穿戴道具相关(产品侧未设计) -- 第三方登录 +`entities/workflow-run` 已实现版本化本地 Store:保存当前运行、刷新恢复、按运行订阅, +并保留 `WorkflowRevision` 版本链供 History 页面读取。从历史步骤重开时追加 Revision, +不能覆盖旧版本。 -页面当前是占位外壳,只声明路由与模块边界。 +本模块 PR 不包含 WorkflowController、Quick Start、Workflow Editor、History 页面或 +Asset Library 页面。History 读取 WorkflowRun 的过程版本;Asset Library 读取已确认的 +Character 资产树,二者不能合并为同一概念。 --- diff --git a/frontend/README.md b/frontend/README.md index 6e01fa34..d4504ee7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,7 @@ npm run dev npm run format:check # 格式 npm run lint # 静态检查 npm run typecheck # 类型 -npm run test # 测试(本阶段无测试文件) +npm run test # 测试 npm run build # 构建 ``` @@ -25,6 +25,7 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 +页面和大部分模块仍是接口骨架。`entities/workflow-run` 已包含版本化 Store、刷新恢复、 +Revision 历史和对应测试;Controller 与页面实现继续按独立 PR 提交。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359dd..57e6f383 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -56,8 +56,9 @@ export type { export type { MediaReference } from './media' /* 工作流 —— 节点与运行状态都由前端管理 */ -export { WORKFLOW_STEP_ORDER } from './workflow-run' +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { + CreateWorkflowRunStoreOptions, CreateWorkflowRunInput, ExportStatus, GenerationStatus, @@ -68,6 +69,7 @@ export type { WorkflowRevision, WorkflowRevisionStatus, WorkflowRun, + WorkflowRunStore, WorkflowRunPurpose, WorkflowRunStatus, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts new file mode 100644 index 00000000..6af1e222 --- /dev/null +++ b/frontend/src/entities/workflow-run/constants.ts @@ -0,0 +1,19 @@ +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 + +/** 当前工作流的固定顺序,也是恢复校验与进度展示的唯一顺序来源。 */ +export const WORKFLOW_STEP_ORDER = [ + 'character-setup', + 'character-template', + 'template-candidate', + 'action-setup', + 'first-frame', + 'complete-animation', + 'review', + 'export', +] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ad8c0b5..2ba64640 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,143 +1,78 @@ import type { Generation } 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' -/** Quick Start 与手动工作流只改变输入方式,共用同一种运行模型。 */ -export type WorkflowDriver = 'ai' | 'manual' +export { WORKFLOW_STEP_ORDER } from './constants' -/** 创建 WorkflowRun 时要完成的用户意图。 */ -export type WorkflowRunPurpose = 'create_character' | 'add_action' - -/** - * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 - * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.nodes 的数组位置表达。 - */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const - -/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] +export type ExportStatus = (typeof EXPORT_STATUSES)[number] -/** - * 步骤的可用性和执行结果;不直接复用后端任务状态。 - * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 - */ -export type WorkflowStepStatus = 'locked' | 'available' | 'active' | 'passed' | 'failed' - -/** - * 单个版本的生命周期。 - * abandoned 表示停止沿用但仍保留为历史。 - */ -export type WorkflowRevisionStatus = 'active' | 'completed' | 'failed' | 'abandoned' - -/** - * 整次流程的汇总状态。 - * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 - * 后端生成任务是否真正停止是独立问题;从历史重启成功后可重新进入 active。 - */ -export type WorkflowRunStatus = 'active' | 'interrupted' | 'completed' | 'failed' - -/** - * 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 - * 它是版本级别的汇总,不是单次生成任务的状态——后者是 TaskStatus。 - */ -export type GenerationStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' - -/** 当前版本在导出阶段的汇总状态。 */ -export type ExportStatus = 'not_exported' | 'exporting' | 'exported' | 'failed' - -/** - * 一个 Revision 中已经进入执行线的流程步骤。 - * 步骤自身不重复保存顺序;其在 nodes 中的数组位置就是该版本的执行顺序。 - */ +/** 一次流程步骤的可恢复快照,不包含页面显示状态。 */ export interface WorkflowStep { - /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ + /** 只用于前端编排和定位,不发送给后端。 */ id: string type: WorkflowStepType status: WorkflowStepStatus - /** 进入步骤时保存的输入快照。 */ input: unknown - /** 步骤完成后的结果或引用;尚无结果时为 null。 */ output: unknown - /** - * 本步骤已提交、结果尚未写回 output 的 Generation ID;没有在途任务时为 null。 - * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 - * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 - * Generation 本身不认识步骤,反向关联不存在。 - * - * 字段名沿用后端的 task_id。步骤类型不能从 Generation.type 反推——后端只有 - * character_image 和 character_action 两种,本步骤是哪一步以 WorkflowStep.type 为准。 - */ + /** 已提交但尚未写回结果的生成任务。 */ taskId: Generation['id'] | null - /** 该步骤沿用或依赖的步骤 ID,用于版本来源追踪,不代表后端执行依赖。 */ + /** 请求已开始但后端任务 ID 尚未返回时的本地防重标识。 */ + submissionId: string | null + /** 失败步骤必须提供原因,其他状态必须为 null。 */ + error: string | null + /** 新版本沿用的历史步骤,用于解释版本来源。 */ referenceStepIds: string[] } -/** - * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 - * - * MVP 只走单条执行线:revisions 恒为一个成员,basedOnRevisionId 与 restartStepId 恒为 null。 - * 「从历史步骤重开并保留旧版本」尚未进入产品定义,结构先留出位置但不实现, - * 避免真要做时改动波及 WorkflowRun 的持久化形状。 - */ +/** 一次可回看的流程版本。重开历史步骤时追加新版本,不覆盖旧版本。 */ export interface WorkflowRevision { id: string - /** 首次创建的版本没有来源,因此为 null。 */ basedOnRevisionId: string | null - /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ restartStepId: string | null status: WorkflowRevisionStatus - /** - * 已进入当前执行线的步骤;数组位置是该版本步骤顺序的唯一来源。 - * 尚未推进到的后续步骤可以不存在;完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 - */ steps: WorkflowStep[] generationStatus: GenerationStatus exportStatus: ExportStatus createdAt: string } -/** - * 一次由前端推进的页面流程。 - * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 - * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 - */ +/** 一次前端创作流程,后端不读取、推进或持久化该结构。 */ export interface WorkflowRun { id: string projectId: string - /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ characterId: string | null - /** 已有角色加动作时的目标造型;新建角色时为 null。 */ outfitId: string | null purpose: WorkflowRunPurpose driver: WorkflowDriver status: WorkflowRunStatus - /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ + /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ currentRevisionId: string - /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ + /** 按创建时间排列;历史版本只读,重开时追加新版本。 */ revisions: WorkflowRevision[] - /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ prompt: string | null } -/** 两种入口共享的创建字段。 */ interface CreateWorkflowRunInputBase { projectId: string driver: WorkflowDriver - /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ prompt?: string } -/** - * 创建 WorkflowRun 的输入。 - * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 - */ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & ( | { @@ -155,3 +90,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..86131209 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { WorkflowRevision, WorkflowRun, WorkflowStep } from './index' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' +import { WORKFLOW_STEP_ORDER } from './constants' + +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem() { + return this.value + } + + setItem(_key: string, value: string) { + if (this.failOnSet) throw new Error('storage full') + this.value = value + } +} + +function createSteps(prefix: string, activeIndex = 0): WorkflowStep[] { + return WORKFLOW_STEP_ORDER.map((type, index) => ({ + id: `${prefix}:${type}`, + type, + status: index === activeIndex ? 'active' : 'locked', + input: null, + output: null, + taskId: null, + submissionId: null, + error: null, + referenceStepIds: [], + })) +} + +function createRevision(id = 'revision-1'): WorkflowRevision { + return { + id, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(id), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-08-03T00:00:00.000Z', + } +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [createRevision()], + prompt: 'Create a hero', + } +} + +function createRunWithHistory(): WorkflowRun { + const first = createRevision() + first.status = 'abandoned' + first.steps = first.steps.map((step, index) => ({ + ...step, + status: index === 0 ? 'passed' : 'locked', + })) + const second = createRevision('revision-2') + second.basedOnRevisionId = first.id + second.restartStepId = first.steps[0]!.id + second.steps[0]!.referenceStepIds = [first.steps[0]!.id] + + return { + ...createRun(), + currentRevisionId: second.id, + revisions: [first, second], + } +} + +describe('createWorkflowRunStore', () => { + it('persists versioned snapshots and returns defensive copies', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const run = createRun() + + store.save(run) + run.prompt = 'changed outside' + const restored = store.get(run.id)! + restored.revisions[0]!.steps[0]!.status = 'failed' + + expect(store.get(run.id)?.prompt).toBe('Create a hero') + expect(store.get(run.id)?.revisions[0]?.steps[0]?.status).toBe('active') + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates a valid revision history and exposes it through list', () => { + const run = createRunWithHistory() + const storage = new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), + ) + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + expect(store.list()).toEqual([run]) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + [ + 'missing history source', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { ...createRunWithHistory().revisions[1], basedOnRevisionId: 'missing' }, + ], + }, + ], + }), + ], + [ + 'unknown referenced step', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { + ...createRunWithHistory().revisions[1], + steps: createRunWithHistory().revisions[1]!.steps.map((step, index) => + index === 0 ? { ...step, referenceStepIds: ['missing-step'] } : step, + ), + }, + ], + }, + ], + }), + ], + ])('ignores %s during hydration', (_label, serialized) => { + expect(createWorkflowRunStore({ storage: new TestStorage(serialized) }).list()).toEqual([]) + }) + + it('rejects invalid snapshots before they reach memory', () => { + const store = createWorkflowRunStore({ storage: null }) + const invalid = createRun() + invalid.revisions[0]!.steps[0]!.error = 'failed without failed status' + + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + expect(store.get(invalid.id)).toBeNull() + }) + + it('keeps memory authoritative when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + + expect(() => store.save(createRun())).not.toThrow() + expect(store.get('run-1')).toEqual(createRun()) + }) + + it('notifies run and history subscribers without sharing mutable values', () => { + const store = createWorkflowRunStore({ storage: null }) + const runListener = vi.fn((run: WorkflowRun) => { + run.prompt = 'listener mutation' + }) + const listListener = vi.fn() + const unsubscribeRun = store.subscribe('run-1', runListener) + const unsubscribeAll = store.subscribeAll(listListener) + + store.save(createRun()) + + expect(store.get('run-1')?.prompt).toBe('Create a hero') + expect(listListener).toHaveBeenCalledWith([createRun()]) + unsubscribeRun() + unsubscribeAll() + store.save({ ...createRun(), prompt: 'second save' }) + expect(runListener).toHaveBeenCalledTimes(1) + expect(listListener).toHaveBeenCalledTimes(1) + }) + + it('uses the stable browser storage key', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ storage: { getItem: () => null, setItem } }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts new file mode 100644 index 00000000..256fb077 --- /dev/null +++ b/frontend/src/entities/workflow-run/store.ts @@ -0,0 +1,264 @@ +import type { WorkflowRevision, WorkflowRun } from './index' +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 = 1 + +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 + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** null 表示仅保存在当前内存;未传时浏览器默认使用 localStorage。 */ + storage?: WorkflowRunStorage | null +} + +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, expectedType: string): boolean { + if (!isRecord(value)) return false + + const errorIsValid = + isNullableString(value.error) && + (value.status === 'failed' + ? typeof value.error === 'string' && value.error.trim().length > 0 + : value.error === null) + const taskStateIsValid = + isNullableString(value.taskId) && + isNullableString(value.submissionId) && + !(value.taskId !== null && value.submissionId !== null) && + ((value.taskId === null && value.submissionId === null) || value.status === 'active') + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + value.type === expectedType && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + 'input' in value && + 'output' in value && + taskStateIsValid && + errorIsValid && + isStringArray(value.referenceStepIds) + ) +} + +function isWorkflowRevision(value: unknown): value is WorkflowRevision { + if (!isRecord(value) || !Array.isArray(value.steps)) return false + const stepIds = value.steps.map((step) => (isRecord(step) ? step.id : null)) + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + value.steps.length === WORKFLOW_STEP_ORDER.length && + value.steps.every((step, index) => isWorkflowStep(step, WORKFLOW_STEP_ORDER[index]!)) && + new Set(stepIds).size === stepIds.length && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +function hasValidRevisionLine(revisions: WorkflowRevision[]): boolean { + const prior = new Map() + const priorStepIds = new Set() + + for (const [index, revision] of revisions.entries()) { + if (prior.has(revision.id)) return false + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if (revision.basedOnRevisionId === null || revision.restartStepId === null) return false + const source = prior.get(revision.basedOnRevisionId) + if ( + !source?.steps.some( + (step) => step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + if ( + revision.steps.some((step) => + step.referenceStepIds.some((stepId) => !priorStepIds.has(stepId)), + ) + ) { + return false + } + prior.set(revision.id, revision) + revision.steps.forEach((step) => priorStepIds.add(step.id)) + } + + return true +} + +function isWorkflowRun(value: unknown): value is WorkflowRun { + if (!isRecord(value) || !Array.isArray(value.revisions) || value.revisions.length === 0) { + return false + } + if (!value.revisions.every(isWorkflowRevision)) return false + + const revisions = value.revisions + const current = revisions.at(-1) + if (!current || current.id !== value.currentRevisionId || !hasValidRevisionLine(revisions)) { + return false + } + + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (current.status !== expectedRevisionStatus) return false + if (revisions.slice(0, -1).some((revision) => revision.status === 'active')) return false + + const activeStepCount = current.steps.filter((step) => step.status === 'active').length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + 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) && + isNullableString(value.prompt) + ) +} + +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + const value: unknown = JSON.parse(serialized) + if ( + !isRecord(value) || + value.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(value.runs) + ) { + return [] + } + return value.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } catch { + return [] + } +} + +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage + } catch { + return null + } +} + +/** 内存快照是当前会话的权威状态,localStorage 仅用于刷新恢复。 */ +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() + + const snapshotList = () => [...runs.values()].map((run) => structuredClone(run)) + + return { + get(runId) { + const run = runs.get(runId) + return run ? structuredClone(run) : null + }, + list: snapshotList, + save(run) { + if (!isWorkflowRun(run)) throw new TypeError('Invalid WorkflowRun snapshot') + const saved = structuredClone(run) + runs.set(saved.id, saved) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 持久化失败不撤销已经写入的当前会话状态。 + } + + for (const listener of listeners.get(saved.id) ?? []) { + try { + listener(structuredClone(saved)) + } catch { + // 一个订阅方失败不能阻断其他订阅方。 + } + } + for (const listener of listListeners) { + try { + listener(snapshotList()) + } catch { + // 历史列表订阅方失败不影响已保存状态。 + } + } + }, + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, + } +} From 29001a17a2e8ab17a5372e401a5d4e5212bbaa04 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:54:31 +0800 Subject: [PATCH 02/27] chore(workflow-run): narrow pull request scope --- frontend-architecture-v3.md | 16 ++++++++-------- frontend/README.md | 5 ++--- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index bfaedc16..ed137eca 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -1,6 +1,6 @@ # Windup 前端架构 -本文记录当前前端的模块划分与依赖规则。功能实现按可独立审核的模块逐步提交。 +本文记录当前前端的模块划分与依赖规则。2026-07-30 按当日评审意见重写:本阶段只提交模块边界与接口,实现进后续 PR。 --- @@ -84,15 +84,15 @@ Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断 --- -## 5. 当前实现范围 +## 5. 本次不包含 -`entities/workflow-run` 已实现版本化本地 Store:保存当前运行、刷新恢复、按运行订阅, -并保留 `WorkflowRevision` 版本链供 History 页面读取。从历史步骤重开时追加 Revision, -不能覆盖旧版本。 +- 任何实现代码(真实请求、假数据、组件内部逻辑) +- 测试文件 +- 图片上传模块(体量太小,本次不单独体现) +- 穿戴道具相关(产品侧未设计) +- 第三方登录 -本模块 PR 不包含 WorkflowController、Quick Start、Workflow Editor、History 页面或 -Asset Library 页面。History 读取 WorkflowRun 的过程版本;Asset Library 读取已确认的 -Character 资产树,二者不能合并为同一概念。 +页面当前是占位外壳,只声明路由与模块边界。 --- diff --git a/frontend/README.md b/frontend/README.md index d4504ee7..6e01fa34 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,7 @@ npm run dev npm run format:check # 格式 npm run lint # 静态检查 npm run typecheck # 类型 -npm run test # 测试 +npm run test # 测试(本阶段无测试文件) npm run build # 构建 ``` @@ -25,7 +25,6 @@ CI 按上面顺序全跑一遍。 模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 -页面和大部分模块仍是接口骨架。`entities/workflow-run` 已包含版本化 Store、刷新恢复、 -Revision 历史和对应测试;Controller 与页面实现继续按独立 PR 提交。 +**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 与后端尚未对齐的接口见 `API_CONTRACT.md`。 From 8453962fcee8c78e60b9287294e1dfba6e5a0ff3 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:57:24 +0800 Subject: [PATCH 03/27] fix(workflow-run): validate add-action targets --- frontend/src/entities/workflow-run/index.ts | 25 ++++++++++++--- .../src/entities/workflow-run/store.test.ts | 31 +++++++++++++++++++ frontend/src/entities/workflow-run/store.ts | 14 +++++++-- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ba64640..f4f7a8e2 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -51,13 +51,10 @@ export interface WorkflowRevision { createdAt: string } -/** 一次前端创作流程,后端不读取、推进或持久化该结构。 */ -export interface WorkflowRun { +/** 两种创作目的共用的运行字段。 */ +interface WorkflowRunBase { id: string projectId: string - characterId: string | null - outfitId: string | null - purpose: WorkflowRunPurpose driver: WorkflowDriver status: WorkflowRunStatus /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ @@ -67,6 +64,24 @@ export interface WorkflowRun { prompt: string | null } +/** + * 一次前端创作流程,后端不读取、推进或持久化该结构。 + * 新建角色允许稍后补齐角色引用;给已有角色添加动作必须从创建起就绑定角色和造型。 + */ +export type WorkflowRun = WorkflowRunBase & + ( + | { + purpose: 'create_character' + characterId: string | null + outfitId: string | null + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + } + ) + interface CreateWorkflowRunInputBase { projectId: string driver: WorkflowDriver diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts index 86131209..0901e0bd 100644 --- a/frontend/src/entities/workflow-run/store.test.ts +++ b/frontend/src/entities/workflow-run/store.test.ts @@ -68,6 +68,15 @@ function createRun(id = 'run-1'): WorkflowRun { } } +function createAddActionRun(): WorkflowRun { + return { + ...createRun('run-add-action'), + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + } +} + function createRunWithHistory(): WorkflowRun { const first = createRevision() first.status = 'abandoned' @@ -168,6 +177,28 @@ describe('createWorkflowRunStore', () => { expect(store.get(invalid.id)).toBeNull() }) + it('requires character and outfit references when adding an action', () => { + const valid = createAddActionRun() + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const invalid = { + ...valid, + characterId: null, + outfitId: null, + } as unknown as WorkflowRun + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + + const hydrated = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), + ), + }) + expect(hydrated.get(invalid.id)).toBeNull() + }) + it('keeps memory authoritative when persistence fails', () => { const storage = new TestStorage() storage.failOnSet = true diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts index 256fb077..8f588625 100644 --- a/frontend/src/entities/workflow-run/store.ts +++ b/frontend/src/entities/workflow-run/store.ts @@ -47,6 +47,10 @@ function isNullableString(value: unknown): value is string | null { return typeof value === 'string' || value === null } +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === 'string') } @@ -159,13 +163,19 @@ function isWorkflowRun(value: unknown): value is WorkflowRun { return false } + const targetIsValid = + value.purpose === 'add_action' + ? isNonEmptyString(value.characterId) && isNonEmptyString(value.outfitId) + : value.purpose === 'create_character' && + ((value.characterId === null && value.outfitId === null) || + (isNonEmptyString(value.characterId) && isNonEmptyString(value.outfitId))) + return ( typeof value.id === 'string' && value.id.length > 0 && typeof value.projectId === 'string' && - isNullableString(value.characterId) && - isNullableString(value.outfitId) && isMember(value.purpose, WORKFLOW_PURPOSES) && + targetIsValid && isMember(value.driver, WORKFLOW_DRIVERS) && isMember(value.status, WORKFLOW_RUN_STATUSES) && isNullableString(value.prompt) From 1b1cd82556a411d54f8ff45045b837f107edcba5 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:58:44 +0800 Subject: [PATCH 04/27] feat(workflow-run): implement generation orchestration --- frontend/src/entities/index.ts | 33 +- .../src/entities/workflow-run/constants.ts | 19 - frontend/src/entities/workflow-run/index.ts | 141 +--- .../entities/workflow-run/model/constants.ts | 63 ++ .../src/entities/workflow-run/model/index.ts | 27 + .../src/entities/workflow-run/model/types.ts | 205 +++++ .../entities/workflow-run/service/index.ts | 20 + .../service/workflow-run-service.test.ts | 322 ++++++++ .../service/workflow-run-service.ts | 706 ++++++++++++++++++ .../src/entities/workflow-run/store.test.ts | 239 ------ frontend/src/entities/workflow-run/store.ts | 274 ------- .../src/entities/workflow-run/store/index.ts | 14 + .../store/workflow-run-store.test.ts | 439 +++++++++++ .../workflow-run/store/workflow-run-store.ts | 465 ++++++++++++ 14 files changed, 2326 insertions(+), 641 deletions(-) delete mode 100644 frontend/src/entities/workflow-run/constants.ts create mode 100644 frontend/src/entities/workflow-run/model/constants.ts create mode 100644 frontend/src/entities/workflow-run/model/index.ts create mode 100644 frontend/src/entities/workflow-run/model/types.ts create mode 100644 frontend/src/entities/workflow-run/service/index.ts create mode 100644 frontend/src/entities/workflow-run/service/workflow-run-service.test.ts create mode 100644 frontend/src/entities/workflow-run/service/workflow-run-service.ts delete mode 100644 frontend/src/entities/workflow-run/store.test.ts delete mode 100644 frontend/src/entities/workflow-run/store.ts create mode 100644 frontend/src/entities/workflow-run/store/index.ts create mode 100644 frontend/src/entities/workflow-run/store/workflow-run-store.test.ts create mode 100644 frontend/src/entities/workflow-run/store/workflow-run-store.ts diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 57e6f383..1075fc24 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,6 +1,12 @@ /** - * entities 唯一公开入口。外部不得绕过本文件访问内部文件。 - * 本次只提交类型与接口,不提交实现。 + * Entity 层的唯一公开入口。 + * + * Page 和 Feature 只从 `@/entities` 导入,不直接访问某个 Entity 的内部文件。 + * 这不是为了少写一段路径,而是为了稳定模块边界:内部文件可以重构, + * 但公开名称和依赖方向必须经过本文件明确审核。 + * + * 这里只暴露 Entity 级别的数据结构、后端端口契约以及必要的本地 Store 工厂。 + * 页面状态、路由、弹窗和按钮行为不属于 Entity,不应从此处导出。 */ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ @@ -55,11 +61,26 @@ export type { /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' -/* 工作流 —— 节点与运行状态都由前端管理 */ -export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' +/* + * 工作流 —— 记录“一次用户任务如何运行”。 + * 它不是角色/动作资产,也不是负责调后端的 WorkflowController。 + */ +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + createWorkflowRunService, + createWorkflowRunStore, + WORKFLOW_STEP_ORDERS, +} from './workflow-run' export type { + ActionFirstFrameCandidateBatch, CreateWorkflowRunStoreOptions, + CreateWorkflowRunServiceOptions, CreateWorkflowRunInput, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, ExportStatus, GenerationStatus, WorkflowDriver, @@ -70,6 +91,10 @@ export type { WorkflowRevisionStatus, WorkflowRun, WorkflowRunStore, + WorkflowRunService, WorkflowRunPurpose, WorkflowRunStatus, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, } from './workflow-run' diff --git a/frontend/src/entities/workflow-run/constants.ts b/frontend/src/entities/workflow-run/constants.ts deleted file mode 100644 index 6af1e222..00000000 --- a/frontend/src/entities/workflow-run/constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 - -/** 当前工作流的固定顺序,也是恢复校验与进度展示的唯一顺序来源。 */ -export const WORKFLOW_STEP_ORDER = [ - 'character-setup', - 'character-template', - 'template-candidate', - 'action-setup', - 'first-frame', - 'complete-animation', - 'review', - 'export', -] as const diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index f4f7a8e2..2ae8a5b9 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,110 +1,41 @@ -import type { Generation } 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 { WORKFLOW_STEP_ORDER } from './constants' - -export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] -export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] -export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] -export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] -export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] -export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] -export type GenerationStatus = (typeof GENERATION_STATUSES)[number] -export type ExportStatus = (typeof EXPORT_STATUSES)[number] - -/** 一次流程步骤的可恢复快照,不包含页面显示状态。 */ -export interface WorkflowStep { - /** 只用于前端编排和定位,不发送给后端。 */ - id: string - type: WorkflowStepType - status: WorkflowStepStatus - input: unknown - output: unknown - /** 已提交但尚未写回结果的生成任务。 */ - taskId: Generation['id'] | null - /** 请求已开始但后端任务 ID 尚未返回时的本地防重标识。 */ - submissionId: string | null - /** 失败步骤必须提供原因,其他状态必须为 null。 */ - error: string | null - /** 新版本沿用的历史步骤,用于解释版本来源。 */ - referenceStepIds: string[] -} - -/** 一次可回看的流程版本。重开历史步骤时追加新版本,不覆盖旧版本。 */ -export interface WorkflowRevision { - id: string - basedOnRevisionId: string | null - restartStepId: string | null - status: WorkflowRevisionStatus - steps: WorkflowStep[] - generationStatus: GenerationStatus - exportStatus: ExportStatus - createdAt: string -} - -/** 两种创作目的共用的运行字段。 */ -interface WorkflowRunBase { - id: string - projectId: string - driver: WorkflowDriver - status: WorkflowRunStatus - /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ - currentRevisionId: string - /** 按创建时间排列;历史版本只读,重开时追加新版本。 */ - revisions: WorkflowRevision[] - prompt: string | null -} - /** - * 一次前端创作流程,后端不读取、推进或持久化该结构。 - * 新建角色允许稍后补齐角色引用;给已有角色添加动作必须从创建起就绑定角色和造型。 + * WorkflowRun Entity 的对外入口。 + * + * 外部模块只从这里获取 WorkflowRun 能力,不绕过入口直接依赖 model/store + * 内部文件。这样既保留了子目录的职责分工,又不把内部结构变成全仓库 API。 */ -export type WorkflowRun = WorkflowRunBase & - ( - | { - purpose: 'create_character' - characterId: string | null - outfitId: string | null - } - | { - purpose: 'add_action' - characterId: string - outfitId: string - } - ) - -interface CreateWorkflowRunInputBase { - projectId: string - driver: WorkflowDriver - prompt?: string -} - -export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & - ( - | { - purpose: 'create_character' - characterId?: never - outfitId?: never - characterTemplateUrl?: never - baseFrameUrls?: never - } - | { - purpose: 'add_action' - characterId: string - outfitId: string - characterTemplateUrl: string - baseFrameUrls: readonly string[] - } - ) +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './model' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, +} from './model' export { createWorkflowRunStore } from './store' export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' +export { createWorkflowRunService } from './service' +export type { + ActionFirstFrameCandidateBatch, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, + CreateWorkflowRunServiceOptions, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + WorkflowRunService, +} from './service' diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts new file mode 100644 index 00000000..375bda34 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -0,0 +1,63 @@ +/** + * WorkflowRun 的业务词汇和步骤模板。 + * + * 常量数组同时服务于三个地方:TypeScript 联合类型、运行时水合校验、 + * 以及页面的进度顺序。只保留一份定义,可以避免“类型说可以,恢复时却拒绝”。 + */ + +/** 该 Run 是由 AI 自动引导,还是用户在编辑器中手动推进。 */ +export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const + +/** + * 一个 Run 只有一个目标。新建角色和追加动作可在同一界面连续操作, + * 但是两次独立任务,因此使用两个 WorkflowRun。 + */ +export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const + +/** Run 级状态:描述整个用户任务,不等于某次后端生成任务的状态。 */ +export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const + +/** + * Revision 级状态。用户从旧步骤重做时,旧 Revision 变为 abandoned, + * 并追加新 Revision;不覆盖历史,才能说清“这个结果从哪次重做而来”。 + */ +export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const + +/** 当前 Revision 中生成阶段的汇总状态,不是单个 GenerationTask.status。 */ +export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const + +/** 导出阶段的汇总状态;角色生成 Run 没有导出步骤时保持 not_exported。 */ +export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const + +/** + * 单个步骤的状态。locked 表示前置条件未满足,available 表示可开始, + * active 表示当前正在处理,passed/failed 是已结束结果。 + */ +export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const + +/** 角色形象每次生成 4 张临时候选;用户只会确认其中 1 张为正式资产。 */ +export const CHARACTER_CANDIDATE_COUNT = 4 + +/** 动作也先生成 4 张独立首帧,避免错误姿势直接扩展成完整动画。 */ +export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 + +/** + * 按任务目的分开定义步骤顺序。 + * + * create_character 到“四选一并保存正式角色”就结束; + * add_action 从已有角色/造型开始,不重复跑角色母版生成。 + * + * 两个 Run 可以由同一页面连续展示,但数据上必须拆开,否则历史记录、 + * 失败重试和后续追加动作都无法准确归属。 + */ +export const WORKFLOW_STEP_ORDERS = { + create_character: ['character-setup', 'character-template', 'template-candidate'], + add_action: [ + 'action-setup', + 'first-frame', + 'first-frame-candidate', + 'complete-animation', + 'review', + 'export', + ], +} as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts new file mode 100644 index 00000000..fe954e68 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -0,0 +1,27 @@ +/** + * WorkflowRun 领域模型的子目录入口。 + * + * 本目录只定义“WorkflowRun 是什么”:业务词汇、步骤模板、Run/Revision/Step + * 类型以及创建输入。它不知道 localStorage、订阅者或页面,因此可被 + * Store、Controller 和页面共同依赖,而不产生反向依赖。 + */ + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' +export type { + CreateWorkflowRunInput, + ExportStatus, + GenerationStatus, + WorkflowDriver, + WorkflowRevision, + WorkflowRevisionStatus, + WorkflowRun, + WorkflowRunPurpose, + WorkflowRunStatus, + WorkflowStep, + WorkflowStepStatus, + WorkflowStepType, +} from './types' diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts new file mode 100644 index 00000000..efda4048 --- /dev/null +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -0,0 +1,205 @@ +/** + * WorkflowRun Entity 的公开业务模型。 + * + * 层级关系是 WorkflowRun(一次用户任务) -> WorkflowRevision(一条重做版本) + * -> WorkflowStep(版本中的一个步骤)。后端 Generation 只是某个步骤引用的异步任务, + * 不能代替 WorkflowRun;角色和动作是最终资产,也不应嵌进运行历史。 + */ + +import type { Generation } from '../../generation' +import type { ActionType } from '../../character' +import { + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_STATUSES, +} from './constants' + +export { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + CHARACTER_CANDIDATE_COUNT, + WORKFLOW_STEP_ORDERS, +} from './constants' + +/** ai/manual 表示运行由哪种交互方式推进,不改变后端数据契约。 */ +export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] + +/** Run 的用户目标,也是选择步骤模板和校验资产引用的判别字段。 */ +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] + +/** 从两套步骤模板自动推导,避免类型与运行顺序手工维护两份。 */ +export type WorkflowStepType = + (typeof WORKFLOW_STEP_ORDERS)[keyof typeof WORKFLOW_STEP_ORDERS][number] +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] +export type ExportStatus = (typeof EXPORT_STATUSES)[number] + +/** + * 一次流程步骤的可恢复快照,不包含页面显示状态。 + * + * 此处故意没有通用 input/output:四张角色候选是后端临时文件,如果把 URL 塞入 + * localStorage,候选删除后就会留下无效历史。可恢复信息通过 taskId、正式资产 ID + * 和 referenceStepIds 表达,候选预览数组只存在当前界面/请求缓存中。 + */ +export interface WorkflowStep { + /** 前端步骤快照 ID,用于 Revision 之间引用;它不是后端 task ID。 */ + id: string + /** 步骤业务类型;必须与当前 purpose 对应的模板位置一致。 */ + type: WorkflowStepType + /** 当前步骤在前端编排中的生命周期。 */ + status: WorkflowStepStatus + /** + * 已由后端接受的 Generation ID。步骤 passed/failed 后仍保留, + * 方便历史查询和问题定位;它只是引用,不复制后端生成结果。 + */ + taskId: Generation['id'] | null + /** + * `first-frame` 一次需要 4 个独立生成任务,所以单独保存它们的 ID。 + * 其他步骤必须保持空数组;候选图 URL 仍不进入快照。 + */ + candidateTaskIds: Generation['id'][] + /** + * 请求已发出、但后端 taskId 尚未返回时的本地防重标识。 + * taskId 返回后必须清空,两者不能同时存在。 + */ + submissionId: string | null + /** 失败步骤必须提供原因,其他状态必须为 null。 */ + error: string | null + /** 新版本沿用的历史步骤,用于解释版本来源。 */ + referenceStepIds: string[] +} + +/** + * 一条可回看的任务执行版本。 + * + * 用户目标不变,只是从某个已通过步骤重做时,在同一 Run 下追加 Revision。 + * 网络重试不创建 Revision;用户改成另一个动作目标时则创建新 Run。 + */ +export interface WorkflowRevision { + /** 本版本 ID。 */ + id: string + /** 首版为 null;重做版本指向它沿用的旧 Revision。 */ + basedOnRevisionId: string | null + /** 首版为 null;重做时记录从旧 Revision 的哪个 passed 步骤重开。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 两种任务共享的运行字段。 + * Run 是历史列表的主体;Revision 是 Run 内部的重做记录,不单独伪装成新任务。 + */ +interface WorkflowRunBase { + /** 一次用户任务的稳定 ID,重做时不变。 */ + id: string + /** 所属项目;历史记录和恢复查询均按项目隔离。 */ + projectId: string + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ + currentRevisionId: string + /** 按创建时间排列;历史版本只读,重开时追加新版本。 */ + revisions: WorkflowRevision[] + /** 用户本次任务的目标描述;界面文案不存在这里。 */ + prompt: string | null + /** Run 创建时间,用于历史排序。 */ + createdAt: string + /** 任何可持久业务状态最后更新的时间。 */ + updatedAt: string +} + +/** + * 一次前端创作任务。当前由前端推进并用 localStorage 恢复,不伪装成已有后端持久化。 + * + * create_character 的两个分支表达同一个生命周期:生成中时还没有正式资产 ID; + * 用户从 4 张候选中选择 1 张且后端保存成功后,才同时写入 characterId、 + * outfitId 和 selectedAt。其余 3 张由后端清理,不进入 WorkflowRun。 + * + * add_action 是另一个 Run,只在用户点击“生成动作”时创建, + * 因此必须从开始就绑定已有 characterId 和 outfitId。它会先生成 + * 4 个独立首帧任务,用户选中 1 张后才进入完整动画生成。 + */ +export type WorkflowRun = WorkflowRunBase & + ( + | { + purpose: 'create_character' + characterId: null + outfitId: null + selectedAt: null + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'create_character' + characterId: string + outfitId: string + /** 选中图片已保存为正式角色资产的时间。 */ + selectedAt: string + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + selectedAt?: never + /** Run 创建时就固定的动作资产 ID,保证审核/发布重试幂等。 */ + actionId: string + /** 用户这次要创建的动作名称,刷新后仍用于正式写入资产。 */ + actionName: string + /** 动作的业务语义,不由生成结果反向猜测。 */ + actionType: ActionType + /** 最终动作资产的默认播放帧率。 */ + fps: number + } + ) + +interface CreateWorkflowRunInputBase { + /** 任务所属项目,不允许空字符串。 */ + projectId: string + /** 由 Quick Start 自动推进,或由工作流编辑器手动推进。 */ + driver: WorkflowDriver + /** 用户任务描述;Store 会去掉首尾空白,空文本按 null 保存。 */ + prompt?: string +} + +/** + * 创建 Run 的判别联合输入。 + * + * 创建角色时尚无资产 ID,所以类型明确禁止传入 characterId/outfitId; + * 追加动作必须定位已有角色的具体造型,所以两个 ID 缺一不可。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + actionName?: never + actionType?: never + actionId?: never + fps?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + actionName: string + actionType: ActionType + fps: number + } + ) diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts new file mode 100644 index 00000000..72bf39b1 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -0,0 +1,20 @@ +/** + * WorkflowRun 可执行用例的子目录入口。 + * + * model 只定义数据,store 只管快照,service 负责组合真实 Character/Generation + * 端口完成角色和动作任务。页面应调用这些用例,不自行改写 Run。 + */ + +export { createWorkflowRunService } from './workflow-run-service' +export type { + ActionFirstFrameCandidateBatch, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, + ConfirmCharacterSelectionInput, + ConfirmActionFirstFrameInput, + CreateWorkflowRunServiceOptions, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + WorkflowRunService, +} from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts new file mode 100644 index 00000000..305a7b7f --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -0,0 +1,322 @@ +/** WorkflowRun Service 的真实用例链测试,不用伪造的页面成功状态代替端口结果。 */ + +import { describe, expect, it, vi } from 'vitest' + +import type { Character, CharacterApis } from '../../character' +import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import { createWorkflowRunStore } from '../store' +import { + createWorkflowRunService, + type CharacterCandidateConfirmationApis, +} from './workflow-run-service' + +function createCharacter(): Character { + return { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: '默认造型', + candidateCharacterTemplates: [], + characterTemplateUrl: 'candidate-2.png', + baseFrames: [], + actions: [], + }, + ], + } +} + +function createGenerationApis() { + const tasks = new Map() + let nextId = 0 + const create = vi.fn(async (input: GenerationInput): Promise => { + const id = `generation-${++nextId}` + const result = + input.type === 'character_template' + ? { + type: 'character_template' as const, + images: [1, 2, 3, 4].map((index) => ({ url: `candidate-${index}.png` })), + } + : input.type === 'first_frame' + ? { type: 'first_frame' as const, image: { url: `first-frame-${id}.png` } } + : { + type: 'complete_animation' as const, + frames: [{ url: 'frame-1.png' }, { url: 'frame-2.png' }], + } + const task: Generation = { + id, + projectId: input.projectId, + type: input.type, + status: 'completed', + result, + error: null, + } + tasks.set(id, task) + return task + }) + const apis: GenerationApis = { + create, + async get(_projectId, id) { + const task = tasks.get(id) + if (!task) throw new Error('任务不存在') + return task + }, + subscribe() { + return () => undefined + }, + } + return { apis, create, tasks } +} + +function createService() { + let id = 0 + let timestamp = 0 + const store = createWorkflowRunStore({ + storage: null, + createId: () => `workflow-id-${++id}`, + now: () => `2026-08-03T00:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + const generation = createGenerationApis() + let character = createCharacter() + const characterApis: CharacterApis = { + get: vi.fn(async () => { + return structuredClone(character) + }), + async listByProject() { + return [structuredClone(character)] + }, + async create() { + return structuredClone(character) + }, + update: vi.fn(async (next: Character) => { + character = structuredClone(next) + return structuredClone(character) + }), + } + const confirmSelection = vi.fn(async () => ({ + character: structuredClone(character), + outfitId: 'outfit-1', + })) + const candidateConfirmationApis: CharacterCandidateConfirmationApis = { confirmSelection } + const service = createWorkflowRunService({ + store, + generationApis: generation.apis, + characterApis, + candidateConfirmationApis, + now: () => `2026-08-03T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, + }) + return { service, store, generation, characterApis, confirmSelection } +} + +describe('createWorkflowRunService', () => { + it('runs character selection and action publishing as two linked user tasks', async () => { + const { service, store, generation, characterApis, confirmSelection } = createService() + + const candidates = await service.startCharacter({ + projectId: 'project-1', + prompt: '一位像素风守夜人', + driver: 'ai', + }) + + expect(candidates.candidates).toEqual([ + 'candidate-1.png', + 'candidate-2.png', + 'candidate-3.png', + 'candidate-4.png', + ]) + expect(candidates.run.purpose).toBe('create_character') + expect( + candidates.run.revisions[0]?.steps.find((step) => step.type === 'template-candidate')?.status, + ).toBe('active') + expect(JSON.stringify(store.get(candidates.run.id))).not.toContain('candidate-1.png') + + const characterRun = await service.confirmCharacter({ + runId: candidates.run.id, + selectedImageUrl: 'candidate-2.png', + }) + expect(characterRun).toMatchObject({ + purpose: 'create_character', + status: 'completed', + characterId: 'character-1', + outfitId: 'outfit-1', + }) + expect(confirmSelection).toHaveBeenCalledWith({ + projectId: 'project-1', + generationId: 'generation-1', + selectedImageUrl: 'candidate-2.png', + description: '一位像素风守夜人', + }) + + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '向前行走', + actionType: 'walk', + prompt: '轻快地向前行走', + fps: 12, + driver: 'ai', + }) + + expect(firstFrames.run.id).not.toBe(characterRun.id) + expect(firstFrames.run.purpose).toBe('add_action') + expect(firstFrames.candidates).toEqual([ + 'first-frame-generation-2.png', + 'first-frame-generation-3.png', + 'first-frame-generation-4.png', + 'first-frame-generation-5.png', + ]) + expect( + firstFrames.run.revisions[0]?.steps.find((step) => step.type === 'first-frame-candidate') + ?.status, + ).toBe('active') + expect(JSON.stringify(store.get(firstFrames.run.id))).not.toContain('first-frame-generation') + expect(generation.create).toHaveBeenCalledTimes(5) + + const actionRun = await service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: firstFrames.candidates[1]!, + }) + expect(actionRun.revisions[0]?.steps.find((step) => step.type === 'review')?.status).toBe( + 'active', + ) + expect(generation.create).toHaveBeenCalledTimes(6) + + const published = await service.approveAction(actionRun.id) + expect(published.run.status).toBe('completed') + expect(published.actionId).toBe(actionRun.actionId) + expect(published.character.outfits[0]?.actions[0]).toMatchObject({ + id: actionRun.actionId, + name: '向前行走', + type: 'walk', + fps: 12, + }) + expect(published.character.outfits[0]?.actions[0]?.frames).toHaveLength(2) + expect(characterApis.update).toHaveBeenCalledTimes(1) + }) + + it('rejects a candidate that was not returned by the current generation task', async () => { + const { service, confirmSelection } = createService() + const batch = await service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'manual', + }) + + await expect( + service.confirmCharacter({ runId: batch.run.id, selectedImageUrl: 'foreign.png' }), + ).rejects.toThrow('选中图片不属于当前角色生成任务') + expect(confirmSelection).not.toHaveBeenCalled() + }) + + it('does not complete the character run when backend confirmation fails', async () => { + const fixture = createService() + fixture.confirmSelection.mockRejectedValueOnce(new Error('后端候选确认失败')) + const batch = await fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'ai', + }) + + await expect( + fixture.service.confirmCharacter({ + runId: batch.run.id, + selectedImageUrl: 'candidate-1.png', + }), + ).rejects.toThrow('后端候选确认失败') + expect(fixture.store.get(batch.run.id)?.status).toBe('active') + }) + + it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { + const { service, store, generation } = createService() + const run = store.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'ai', + prompt: '向前行走', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + const restored = structuredClone(run) + const revision = restored.revisions[0]! + revision.steps[0]!.status = 'passed' + revision.steps[1]!.status = 'active' + revision.steps[1]!.candidateTaskIds = ['persisted-first-frame-1', 'persisted-first-frame-2'] + revision.generationStatus = 'in_progress' + store.save(restored) + for (const index of [1, 2]) { + generation.tasks.set(`persisted-first-frame-${index}`, { + id: `persisted-first-frame-${index}`, + projectId: 'project-1', + type: 'first_frame', + status: 'completed', + result: { type: 'first_frame', image: { url: `restored-first-frame-${index}.png` } }, + error: null, + }) + } + + const resumed = await service.resumeActionFirstFrameCandidates(run.id) + + expect(generation.create).toHaveBeenCalledTimes(2) + expect(resumed.candidates).toHaveLength(4) + expect(resumed.candidates.slice(0, 2)).toEqual([ + 'restored-first-frame-1.png', + 'restored-first-frame-2.png', + ]) + expect(resumed.run.revisions[0]?.steps[2]?.type).toBe('first-frame-candidate') + expect(resumed.run.revisions[0]?.steps[2]?.status).toBe('active') + }) + + it('restores an action already in review without rerunning generation', async () => { + const { service, generation, characterApis } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + const actionRun = await service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: firstFrames.candidates[0]!, + }) + expect(generation.create).toHaveBeenCalledTimes(5) + expect(characterApis.get).toHaveBeenCalledTimes(2) + + const resumed = await service.resumeAction(actionRun.id) + + expect(resumed).toEqual(actionRun) + expect(generation.create).toHaveBeenCalledTimes(5) + expect(characterApis.get).toHaveBeenCalledTimes(2) + }) + + it('rejects a first-frame image that is not one of the four current candidates', async () => { + const { service, generation } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + + await expect( + service.confirmActionFirstFrame({ + runId: firstFrames.run.id, + selectedImageUrl: 'foreign-first-frame.png', + }), + ).rejects.toThrow('选中图片不属于当前动作首帧任务') + expect(generation.create).toHaveBeenCalledTimes(4) + }) +}) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts new file mode 100644 index 00000000..3bd51257 --- /dev/null +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -0,0 +1,706 @@ +/** + * WorkflowRun 的可执行前端用例。 + * + * Store 只保存快照,本 Service 才真正组合 Generation/Character 端口完成业务: + * 生成 4 张角色候选、确认 1 张为正式角色、创建独立动作 Run、 + * 生成 4 张动作首帧候选、根据选中首帧生成完整动画, + * 并在审核后写入角色资产。 + */ + +import type { Action, ActionType, Character, CharacterApis, Frame } from '../../character' +import type { + CharacterTemplateGenerationResult, + CompleteAnimationGenerationResult, + Generation, + GenerationApis, + GenerationEvent, +} from '../../generation' +import type { MediaReference } from '../../media' +import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' +import type { WorkflowRevision, WorkflowRun, WorkflowStep, WorkflowStepType } from '../model' +import type { WorkflowRunStore } from '../store' + +/** + * 确认角色候选的后端原子操作。 + * + * 后端必须在同一用例中保存选中图、返回正式角色/造型 ID, + * 并安排清理同一 generationId 下的其余 3 张候选。 + * 前端不能用“先创建角色、再单独删图”的两步请求伪装原子性。 + */ +export interface CharacterCandidateConfirmationApis { + confirmSelection(input: { + projectId: string + generationId: string + selectedImageUrl: string + description: string + }): Promise<{ character: Character; outfitId: string }> +} + +export interface StartCharacterRunInput { + projectId: string + prompt: string + driver: 'ai' | 'manual' + referenceMedia?: readonly MediaReference[] +} + +export interface CharacterCandidateBatch { + run: WorkflowRun + generationId: string + /** 仅供当前选择界面使用,不写入 WorkflowRun/localStorage。 */ + candidates: readonly string[] +} + +export interface ConfirmCharacterSelectionInput { + runId: string + selectedImageUrl: string +} + +export interface StartActionRunInput { + projectId: string + characterId: string + outfitId: string + actionName: string + actionType: ActionType + prompt?: string | null + fps: number + driver: 'ai' | 'manual' +} + +export interface ActionFirstFrameCandidateBatch { + run: WorkflowRun + /** 4 张图分别对应 4 个后端 Generation,顺序与 candidateTaskIds 一致。 */ + candidateTaskIds: readonly string[] + /** 仅供当前首帧选择界面使用,不写入 WorkflowRun/localStorage。 */ + candidates: readonly string[] +} + +export interface ConfirmActionFirstFrameInput { + runId: string + selectedImageUrl: string +} + +export interface PublishActionResult { + run: WorkflowRun + character: Character + characterId: string + outfitId: string + actionId: string +} + +export interface WorkflowRunService { + startCharacter(input: StartCharacterRunInput): Promise + resumeCharacterCandidates(runId: string): Promise + confirmCharacter(input: ConfirmCharacterSelectionInput): Promise + startAction(input: StartActionRunInput): Promise + resumeActionFirstFrameCandidates(runId: string): Promise + confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise + resumeAction(runId: string): Promise + approveAction(runId: string): Promise +} + +export interface CreateWorkflowRunServiceOptions { + store: WorkflowRunStore + generationApis: GenerationApis + characterApis: CharacterApis + candidateConfirmationApis: CharacterCandidateConfirmationApis + now?: () => string +} + +export function createWorkflowRunService({ + store, + generationApis, + characterApis, + candidateConfirmationApis, + now = () => new Date().toISOString(), +}: CreateWorkflowRunServiceOptions): WorkflowRunService { + async function startCharacter(input: StartCharacterRunInput): Promise { + const prompt = input.prompt.trim() + if (!prompt) throw new Error('请先描述想要创建的角色') + + let run = store.create({ + projectId: input.projectId, + purpose: 'create_character', + driver: input.driver, + prompt, + }) + run = advanceStep(run, 'character-setup', 'character-template', now()) + store.save(run) + + try { + const generation = await generationApis.create({ + type: 'character_template', + projectId: run.projectId, + prompt, + referenceMedia: input.referenceMedia ?? [], + }) + run = recordTask(run, 'character-template', generation.id, now()) + store.save(run) + const terminal = await waitForTerminal(generationApis, generation) + const result = requireCharacterCandidates(terminal) + run = completeGenerationStep( + requireRun(store, run.id), + 'character-template', + 'template-candidate', + now(), + ) + store.save(run) + return toCandidateBatch(run, terminal.id, result) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '角色候选生成失败'), now()) + throw asError(cause) + } + } + + async function resumeCharacterCandidates(runId: string): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'create_character') throw new Error('该 WorkflowRun 不是角色生成任务') + const templateStep = requireStep(run, 'character-template') + if (!templateStep.taskId) throw new Error('角色生成任务 ID 不存在,无法恢复候选') + + const terminal = await waitForTerminal( + generationApis, + await generationApis.get(run.projectId, templateStep.taskId), + ) + const result = requireCharacterCandidates(terminal) + if (templateStep.status === 'active') { + run = completeGenerationStep(run, 'character-template', 'template-candidate', now()) + store.save(run) + } + return toCandidateBatch(run, terminal.id, result) + } + + async function confirmCharacter(input: ConfirmCharacterSelectionInput): Promise { + const batch = await resumeCharacterCandidates(input.runId) + if (!batch.candidates.includes(input.selectedImageUrl)) { + throw new Error('选中图片不属于当前角色生成任务') + } + const run = batch.run + if (run.status !== 'active' || requireStep(run, 'template-candidate').status !== 'active') { + throw new Error('当前 WorkflowRun 不在候选确认阶段') + } + + const confirmed = await candidateConfirmationApis.confirmSelection({ + projectId: run.projectId, + generationId: batch.generationId, + selectedImageUrl: input.selectedImageUrl, + description: run.prompt ?? '', + }) + const outfit = confirmed.character.outfits.find((item) => item.id === confirmed.outfitId) + if ( + confirmed.character.projectId !== run.projectId || + !confirmed.character.id.trim() || + !outfit || + outfit.characterId !== confirmed.character.id + ) { + throw new Error('候选确认接口没有返回有效的角色与造型') + } + + const selectedAt = now() + const completed = editCurrentRevision(run, selectedAt, (revision) => { + const candidate = revision.steps.find((step) => step.type === 'template-candidate')! + candidate.status = 'passed' + revision.status = 'completed' + revision.generationStatus = 'completed' + }) as WorkflowRun + if (completed.purpose !== 'create_character') { + throw new Error('角色确认过程中 WorkflowRun 目的发生了变化') + } + const result: WorkflowRun = { + ...completed, + purpose: 'create_character', + status: 'completed', + characterId: confirmed.character.id, + outfitId: confirmed.outfitId, + selectedAt, + updatedAt: selectedAt, + } + store.save(result) + return result + } + + async function startAction(input: StartActionRunInput): Promise { + if (!input.actionName.trim()) throw new Error('请先填写动作名称') + if (!Number.isFinite(input.fps) || input.fps <= 0) throw new Error('FPS 必须大于 0') + + // 在创建 Run 前校验正式角色,避免错误 ID 留下永远无法继续的空历史。 + const characterImageUrl = await loadCharacterImage( + input.projectId, + input.characterId, + input.outfitId, + ) + + let run = store.create({ + projectId: input.projectId, + purpose: 'add_action', + driver: input.driver, + prompt: input.prompt?.trim() || undefined, + characterId: input.characterId, + outfitId: input.outfitId, + actionName: input.actionName.trim(), + actionType: input.actionType, + fps: input.fps, + }) + run = advanceStep(run, 'action-setup', 'first-frame', now()) + store.save(run) + + try { + return await collectActionFirstFrameCandidates(run.id, characterImageUrl) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选生成失败'), now()) + throw asError(cause) + } + } + + async function resumeActionFirstFrameCandidates( + runId: string, + ): Promise { + const run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active') throw new Error('动作任务已经结束,无法恢复首帧候选') + const characterImageUrl = await loadCharacterImage(run.projectId, run.characterId, run.outfitId) + try { + return await collectActionFirstFrameCandidates(run.id, characterImageUrl) + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选恢复失败'), now()) + throw asError(cause) + } + } + + async function loadCharacterImage( + projectId: string, + characterId: string, + outfitId: string, + ): Promise { + const character = await characterApis.get(characterId) + if (character.projectId !== projectId) throw new Error('动作角色不属于当前项目') + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit || outfit.characterId !== characterId) throw new Error('动作所属角色造型不存在') + if (!outfit.characterTemplateUrl) throw new Error('正式角色造型没有可用的角色图') + return outfit.characterTemplateUrl + } + + async function collectActionFirstFrameCandidates( + runId: string, + characterImageUrl: string, + ): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + let firstFrameStep = requireStep(run, 'first-frame') + + if (firstFrameStep.status === 'active') { + while (firstFrameStep.candidateTaskIds.length < ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + if (run.purpose !== 'add_action') { + throw new Error('动作首帧生成过程中 WorkflowRun 目的发生了变化') + } + const task = await generationApis.create({ + type: 'first_frame', + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + actionType: run.actionType, + prompt: run.prompt, + referenceMedia: [characterImageUrl as MediaReference], + }) + run = appendCandidateTask(run, 'first-frame', task.id, now()) + store.save(run) + firstFrameStep = requireStep(run, 'first-frame') + } + } else if ( + firstFrameStep.status !== 'passed' || + requireStep(run, 'first-frame-candidate').status !== 'active' + ) { + throw new Error('当前 WorkflowRun 不在动作首帧选择阶段') + } + + const taskIds = requireStep(run, 'first-frame').candidateTaskIds + const terminals = await Promise.all( + taskIds.map(async (taskId) => + waitForTerminal(generationApis, await generationApis.get(run.projectId, taskId)), + ), + ) + const candidates = terminals.map(requireFirstFrame) + if (firstFrameStep.status === 'active') { + run = completeGenerationStep(run, 'first-frame', 'first-frame-candidate', now()) + store.save(run) + } + return { run, candidateTaskIds: taskIds, candidates } + } + + async function confirmActionFirstFrame( + input: ConfirmActionFirstFrameInput, + ): Promise { + const batch = await resumeActionFirstFrameCandidates(input.runId) + if (!batch.candidates.includes(input.selectedImageUrl)) { + throw new Error('选中图片不属于当前动作首帧任务') + } + const run = batch.run + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + + try { + const animationTask = await generationApis.create({ + type: 'complete_animation', + projectId: run.projectId, + characterId: run.characterId, + outfitId: run.outfitId, + actionType: run.actionType, + firstFrameUrl: input.selectedImageUrl, + prompt: run.prompt, + referenceMedia: [], + }) + const generating = startAnimationFromCandidate(run, animationTask.id, now()) + store.save(generating) + const terminal = await waitForTerminal(generationApis, animationTask) + requireAnimation(terminal) + const completed = completeGenerationStep( + requireRun(store, run.id), + 'complete-animation', + 'review', + now(), + ) + store.save(completed) + return completed + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '完整动画生成失败'), now()) + throw asError(cause) + } + } + + async function resumeAction(runId: string): Promise { + let run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active' || requireStep(run, 'review').status === 'active') return run + const animationStep = requireStep(run, 'complete-animation') + if (animationStep.status !== 'active') return run + if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在,无法恢复') + + try { + const terminal = await waitForTerminal( + generationApis, + await generationApis.get(run.projectId, animationStep.taskId), + ) + requireAnimation(terminal) + run = completeGenerationStep(run, 'complete-animation', 'review', now()) + store.save(run) + return run + } catch (cause) { + failActiveRun(store, run.id, errorMessage(cause, '完整动画恢复失败'), now()) + throw asError(cause) + } + } + + async function approveAction(runId: string): Promise { + const run = requireRun(store, runId) + if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') + if (run.status !== 'active' || requireStep(run, 'review').status !== 'active') { + throw new Error('动作尚未进入可审核状态') + } + const animationStep = requireStep(run, 'complete-animation') + if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在') + const animation = requireAnimation( + await generationApis.get(run.projectId, animationStep.taskId), + ) + + const character = await characterApis.get(run.characterId) + const outfit = character.outfits.find((item) => item.id === run.outfitId) + if (!outfit) throw new Error('动作所属造型不存在') + const action: Action = { + id: run.actionId, + outfitId: outfit.id, + name: run.actionName, + kind: 'custom', + type: run.actionType, + fps: run.fps, + keyFrameIndex: null, + frames: animation.frames.map((frame) => ({ + imageUrl: frame.url, + durationMs: null, + rootMotion: null, + })), + } + const saved = await characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfit.id + ? { + ...item, + actions: [...item.actions.filter((existing) => existing.id !== run.actionId), action], + } + : item, + ), + }) + + const completedAt = now() + const completed = editCurrentRevision(run, completedAt, (revision) => { + requireRevisionStep(revision, 'review').status = 'passed' + requireRevisionStep(revision, 'export').status = 'passed' + revision.status = 'completed' + revision.exportStatus = 'exported' + }) as WorkflowRun + const result: WorkflowRun = { + ...completed, + status: 'completed', + updatedAt: completedAt, + } + store.save(result) + return { + run: result, + character: saved, + characterId: run.characterId, + outfitId: run.outfitId, + actionId: run.actionId, + } + } + + return { + startCharacter, + resumeCharacterCandidates, + confirmCharacter, + startAction, + resumeActionFirstFrameCandidates, + confirmActionFirstFrame, + resumeAction, + approveAction, + } +} + +function requireRun(store: WorkflowRunStore, runId: string): WorkflowRun { + const run = store.get(runId) + if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) + return run +} + +function currentRevision(run: WorkflowRun): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') + return revision +} + +function requireRevisionStep(revision: WorkflowRevision, type: WorkflowStepType): WorkflowStep { + const step = revision.steps.find((item) => item.type === type) + if (!step) throw new Error(`WorkflowRun 缺少 ${type} 步骤`) + return step +} + +function requireStep(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { + return requireRevisionStep(currentRevision(run), type) +} + +function editCurrentRevision( + run: WorkflowRun, + updatedAt: string, + edit: (revision: WorkflowRevision) => void, +): WorkflowRun { + const next = structuredClone(run) + edit(currentRevision(next)) + next.updatedAt = updatedAt + return next +} + +function advanceStep( + run: WorkflowRun, + currentType: WorkflowStepType, + nextType: WorkflowStepType, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const current = requireRevisionStep(revision, currentType) + const next = requireRevisionStep(revision, nextType) + if (current.status !== 'active' || next.status !== 'locked') { + throw new Error(`不能从 ${currentType} 推进到 ${nextType}`) + } + current.status = 'passed' + next.status = 'active' + }) +} + +function recordTask( + run: WorkflowRun, + type: WorkflowStepType, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const step = requireRevisionStep(revision, type) + if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) + step.taskId = taskId + step.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +/** + * 每次后端成功返回一个首帧任务 ID 就立即保存。 + * 如果第 3 个请求时页面刷新,恢复后只需补齐缺少的任务, + * 不会重复提交前两个。 + */ +function appendCandidateTask( + run: WorkflowRun, + type: WorkflowStepType, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const step = requireRevisionStep(revision, type) + if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) + if (step.candidateTaskIds.includes(taskId)) throw new Error('首帧候选任务 ID 重复') + if (step.candidateTaskIds.length >= ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + throw new Error('首帧候选任务数量已达上限') + } + step.candidateTaskIds.push(taskId) + step.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +/** 选中首帧后,把候选步骤和完整动画 taskId 一次写入同一份快照。 */ +function startAnimationFromCandidate( + run: WorkflowRun, + taskId: string, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const candidate = requireRevisionStep(revision, 'first-frame-candidate') + const animation = requireRevisionStep(revision, 'complete-animation') + if (candidate.status !== 'active' || animation.status !== 'locked') { + throw new Error('当前 WorkflowRun 不能从首帧候选进入完整动画') + } + candidate.status = 'passed' + animation.status = 'active' + animation.taskId = taskId + animation.submissionId = null + revision.generationStatus = 'in_progress' + }) +} + +function completeGenerationStep( + run: WorkflowRun, + currentType: WorkflowStepType, + nextType: WorkflowStepType, + updatedAt: string, +): WorkflowRun { + return editCurrentRevision(run, updatedAt, (revision) => { + const current = requireRevisionStep(revision, currentType) + const next = requireRevisionStep(revision, nextType) + const hasGenerationTasks = + current.taskId !== null || + (current.type === 'first-frame' && + current.candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) + if (current.status !== 'active' || !hasGenerationTasks || next.status !== 'locked') { + throw new Error(`${currentType} 步骤没有可完成的生成任务`) + } + current.status = 'passed' + next.status = 'active' + revision.generationStatus = nextType === 'complete-animation' ? 'in_progress' : 'completed' + }) +} + +function failActiveRun( + store: WorkflowRunStore, + runId: string, + message: string, + updatedAt: string, +): void { + const existing = store.get(runId) + if (!existing || existing.status !== 'active') return + const failed = editCurrentRevision(existing, updatedAt, (revision) => { + const active = revision.steps.find((step) => step.status === 'active') + if (active) { + active.status = 'failed' + active.error = message + active.submissionId = null + } + revision.status = 'failed' + revision.generationStatus = 'failed' + }) + failed.status = 'failed' + store.save(failed) +} + +function requireCharacterCandidates(generation: Generation): CharacterTemplateGenerationResult { + if (generation.status === 'failed') throw new Error(generation.error || '角色候选生成失败') + if ( + generation.type !== 'character_template' || + generation.status !== 'completed' || + generation.result?.type !== 'character_template' || + generation.result.images.length !== CHARACTER_CANDIDATE_COUNT || + generation.result.images.some((image) => !image.url) + ) { + throw new Error(`角色生成必须返回 ${CHARACTER_CANDIDATE_COUNT} 张有效候选图`) + } + return generation.result +} + +function requireAnimation(generation: Generation): CompleteAnimationGenerationResult { + if (generation.status === 'failed') throw new Error(generation.error || '完整动画生成失败') + if ( + generation.type !== 'complete_animation' || + generation.status !== 'completed' || + generation.result?.type !== 'complete_animation' || + generation.result.frames.length === 0 || + generation.result.frames.some((frame) => !frame.url) + ) { + throw new Error('完整动画任务没有返回有效帧') + } + return generation.result +} + +function requireFirstFrame(generation: Generation): string { + if (generation.status === 'failed') throw new Error(generation.error || '首帧生成失败') + if ( + generation.type !== 'first_frame' || + generation.status !== 'completed' || + generation.result?.type !== 'first_frame' || + !generation.result.image.url + ) { + throw new Error('首帧生成未返回有效图片') + } + return generation.result.image.url +} + +function toCandidateBatch( + run: WorkflowRun, + generationId: string, + result: CharacterTemplateGenerationResult, +): CharacterCandidateBatch { + return { run, generationId, candidates: result.images.map((image) => image.url) } +} + +function waitForTerminal( + generationApis: GenerationApis, + generation: Generation, +): Promise { + if (generation.status === 'completed' || generation.status === 'failed') { + return Promise.resolve(generation) + } + return new Promise((resolve, reject) => { + let stop: () => void = () => undefined + let settledBeforeSubscription = false + const settle = (event: GenerationEvent) => { + if (event.status !== 'completed' && event.status !== 'failed') return + settledBeforeSubscription = true + stop() + resolve({ + id: event.taskId, + projectId: generation.projectId, + type: event.type, + status: event.status, + result: event.result, + error: event.error, + }) + } + try { + stop = generationApis.subscribe(generation.projectId, generation.id, settle) + if (settledBeforeSubscription) stop() + } catch (cause) { + reject(asError(cause)) + } + }) +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback +} + +function asError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)) +} diff --git a/frontend/src/entities/workflow-run/store.test.ts b/frontend/src/entities/workflow-run/store.test.ts deleted file mode 100644 index 0901e0bd..00000000 --- a/frontend/src/entities/workflow-run/store.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import type { WorkflowRevision, WorkflowRun, WorkflowStep } from './index' -import { - createWorkflowRunStore, - WORKFLOW_RUN_STORAGE_KEY, - WORKFLOW_RUN_STORAGE_VERSION, -} from './store' -import { WORKFLOW_STEP_ORDER } from './constants' - -class TestStorage { - value: string | null - failOnSet = false - - constructor(value: string | null = null) { - this.value = value - } - - getItem() { - return this.value - } - - setItem(_key: string, value: string) { - if (this.failOnSet) throw new Error('storage full') - this.value = value - } -} - -function createSteps(prefix: string, activeIndex = 0): WorkflowStep[] { - return WORKFLOW_STEP_ORDER.map((type, index) => ({ - id: `${prefix}:${type}`, - type, - status: index === activeIndex ? 'active' : 'locked', - input: null, - output: null, - taskId: null, - submissionId: null, - error: null, - referenceStepIds: [], - })) -} - -function createRevision(id = 'revision-1'): WorkflowRevision { - return { - id, - basedOnRevisionId: null, - restartStepId: null, - status: 'active', - steps: createSteps(id), - generationStatus: 'not_started', - exportStatus: 'not_exported', - createdAt: '2026-08-03T00:00:00.000Z', - } -} - -function createRun(id = 'run-1'): WorkflowRun { - return { - id, - projectId: 'project-1', - characterId: null, - outfitId: null, - purpose: 'create_character', - driver: 'ai', - status: 'active', - currentRevisionId: 'revision-1', - revisions: [createRevision()], - prompt: 'Create a hero', - } -} - -function createAddActionRun(): WorkflowRun { - return { - ...createRun('run-add-action'), - purpose: 'add_action', - characterId: 'character-1', - outfitId: 'outfit-1', - } -} - -function createRunWithHistory(): WorkflowRun { - const first = createRevision() - first.status = 'abandoned' - first.steps = first.steps.map((step, index) => ({ - ...step, - status: index === 0 ? 'passed' : 'locked', - })) - const second = createRevision('revision-2') - second.basedOnRevisionId = first.id - second.restartStepId = first.steps[0]!.id - second.steps[0]!.referenceStepIds = [first.steps[0]!.id] - - return { - ...createRun(), - currentRevisionId: second.id, - revisions: [first, second], - } -} - -describe('createWorkflowRunStore', () => { - it('persists versioned snapshots and returns defensive copies', () => { - const storage = new TestStorage() - const store = createWorkflowRunStore({ storage }) - const run = createRun() - - store.save(run) - run.prompt = 'changed outside' - const restored = store.get(run.id)! - restored.revisions[0]!.steps[0]!.status = 'failed' - - expect(store.get(run.id)?.prompt).toBe('Create a hero') - expect(store.get(run.id)?.revisions[0]?.steps[0]?.status).toBe('active') - expect(JSON.parse(storage.value!)).toEqual({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [createRun()], - }) - }) - - it('hydrates a valid revision history and exposes it through list', () => { - const run = createRunWithHistory() - const storage = new TestStorage( - JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), - ) - const store = createWorkflowRunStore({ storage }) - - expect(store.get(run.id)).toEqual(run) - expect(store.list()).toEqual([run]) - }) - - it.each([ - ['invalid JSON', '{'], - ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], - [ - 'missing history source', - JSON.stringify({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [ - { - ...createRunWithHistory(), - revisions: [ - createRunWithHistory().revisions[0], - { ...createRunWithHistory().revisions[1], basedOnRevisionId: 'missing' }, - ], - }, - ], - }), - ], - [ - 'unknown referenced step', - JSON.stringify({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [ - { - ...createRunWithHistory(), - revisions: [ - createRunWithHistory().revisions[0], - { - ...createRunWithHistory().revisions[1], - steps: createRunWithHistory().revisions[1]!.steps.map((step, index) => - index === 0 ? { ...step, referenceStepIds: ['missing-step'] } : step, - ), - }, - ], - }, - ], - }), - ], - ])('ignores %s during hydration', (_label, serialized) => { - expect(createWorkflowRunStore({ storage: new TestStorage(serialized) }).list()).toEqual([]) - }) - - it('rejects invalid snapshots before they reach memory', () => { - const store = createWorkflowRunStore({ storage: null }) - const invalid = createRun() - invalid.revisions[0]!.steps[0]!.error = 'failed without failed status' - - expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') - expect(store.get(invalid.id)).toBeNull() - }) - - it('requires character and outfit references when adding an action', () => { - const valid = createAddActionRun() - const store = createWorkflowRunStore({ storage: null }) - - store.save(valid) - expect(store.get(valid.id)).toEqual(valid) - - const invalid = { - ...valid, - characterId: null, - outfitId: null, - } as unknown as WorkflowRun - expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') - - const hydrated = createWorkflowRunStore({ - storage: new TestStorage( - JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), - ), - }) - expect(hydrated.get(invalid.id)).toBeNull() - }) - - it('keeps memory authoritative when persistence fails', () => { - const storage = new TestStorage() - storage.failOnSet = true - const store = createWorkflowRunStore({ storage }) - - expect(() => store.save(createRun())).not.toThrow() - expect(store.get('run-1')).toEqual(createRun()) - }) - - it('notifies run and history subscribers without sharing mutable values', () => { - const store = createWorkflowRunStore({ storage: null }) - const runListener = vi.fn((run: WorkflowRun) => { - run.prompt = 'listener mutation' - }) - const listListener = vi.fn() - const unsubscribeRun = store.subscribe('run-1', runListener) - const unsubscribeAll = store.subscribeAll(listListener) - - store.save(createRun()) - - expect(store.get('run-1')?.prompt).toBe('Create a hero') - expect(listListener).toHaveBeenCalledWith([createRun()]) - unsubscribeRun() - unsubscribeAll() - store.save({ ...createRun(), prompt: 'second save' }) - expect(runListener).toHaveBeenCalledTimes(1) - expect(listListener).toHaveBeenCalledTimes(1) - }) - - it('uses the stable browser storage key', () => { - const setItem = vi.fn() - const store = createWorkflowRunStore({ storage: { getItem: () => null, setItem } }) - - store.save(createRun()) - - expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) - }) -}) diff --git a/frontend/src/entities/workflow-run/store.ts b/frontend/src/entities/workflow-run/store.ts deleted file mode 100644 index 8f588625..00000000 --- a/frontend/src/entities/workflow-run/store.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { WorkflowRevision, WorkflowRun } from './index' -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 = 1 - -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 - list(): WorkflowRun[] - save(run: WorkflowRun): void - subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void - subscribeAll(listener: WorkflowRunListListener): () => void -} - -export interface CreateWorkflowRunStoreOptions { - /** null 表示仅保存在当前内存;未传时浏览器默认使用 localStorage。 */ - storage?: WorkflowRunStorage | null -} - -interface PersistedWorkflowRuns { - version: typeof WORKFLOW_RUN_STORAGE_VERSION - runs: WorkflowRun[] -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isNullableString(value: unknown): value is string | null { - return typeof value === 'string' || value === null -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === 'string') -} - -function isMember(value: unknown, members: readonly T[]): value is T { - return typeof value === 'string' && members.includes(value as T) -} - -function isWorkflowStep(value: unknown, expectedType: string): boolean { - if (!isRecord(value)) return false - - const errorIsValid = - isNullableString(value.error) && - (value.status === 'failed' - ? typeof value.error === 'string' && value.error.trim().length > 0 - : value.error === null) - const taskStateIsValid = - isNullableString(value.taskId) && - isNullableString(value.submissionId) && - !(value.taskId !== null && value.submissionId !== null) && - ((value.taskId === null && value.submissionId === null) || value.status === 'active') - - return ( - typeof value.id === 'string' && - value.id.length > 0 && - value.type === expectedType && - isMember(value.status, WORKFLOW_STEP_STATUSES) && - 'input' in value && - 'output' in value && - taskStateIsValid && - errorIsValid && - isStringArray(value.referenceStepIds) - ) -} - -function isWorkflowRevision(value: unknown): value is WorkflowRevision { - if (!isRecord(value) || !Array.isArray(value.steps)) return false - const stepIds = value.steps.map((step) => (isRecord(step) ? step.id : null)) - - return ( - typeof value.id === 'string' && - value.id.length > 0 && - isNullableString(value.basedOnRevisionId) && - isNullableString(value.restartStepId) && - isMember(value.status, WORKFLOW_REVISION_STATUSES) && - value.steps.length === WORKFLOW_STEP_ORDER.length && - value.steps.every((step, index) => isWorkflowStep(step, WORKFLOW_STEP_ORDER[index]!)) && - new Set(stepIds).size === stepIds.length && - isMember(value.generationStatus, GENERATION_STATUSES) && - isMember(value.exportStatus, EXPORT_STATUSES) && - typeof value.createdAt === 'string' - ) -} - -function hasValidRevisionLine(revisions: WorkflowRevision[]): boolean { - const prior = new Map() - const priorStepIds = new Set() - - for (const [index, revision] of revisions.entries()) { - if (prior.has(revision.id)) return false - if (index === 0) { - if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false - } else { - if (revision.basedOnRevisionId === null || revision.restartStepId === null) return false - const source = prior.get(revision.basedOnRevisionId) - if ( - !source?.steps.some( - (step) => step.id === revision.restartStepId && step.status === 'passed', - ) - ) { - return false - } - } - if ( - revision.steps.some((step) => - step.referenceStepIds.some((stepId) => !priorStepIds.has(stepId)), - ) - ) { - return false - } - prior.set(revision.id, revision) - revision.steps.forEach((step) => priorStepIds.add(step.id)) - } - - return true -} - -function isWorkflowRun(value: unknown): value is WorkflowRun { - if (!isRecord(value) || !Array.isArray(value.revisions) || value.revisions.length === 0) { - return false - } - if (!value.revisions.every(isWorkflowRevision)) return false - - const revisions = value.revisions - const current = revisions.at(-1) - if (!current || current.id !== value.currentRevisionId || !hasValidRevisionLine(revisions)) { - return false - } - - const expectedRevisionStatus = - value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' - if (current.status !== expectedRevisionStatus) return false - if (revisions.slice(0, -1).some((revision) => revision.status === 'active')) return false - - const activeStepCount = current.steps.filter((step) => step.status === 'active').length - if ( - ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || - ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) - ) { - return false - } - - const targetIsValid = - value.purpose === 'add_action' - ? isNonEmptyString(value.characterId) && isNonEmptyString(value.outfitId) - : value.purpose === 'create_character' && - ((value.characterId === null && value.outfitId === null) || - (isNonEmptyString(value.characterId) && isNonEmptyString(value.outfitId))) - - return ( - typeof value.id === 'string' && - value.id.length > 0 && - typeof value.projectId === 'string' && - isMember(value.purpose, WORKFLOW_PURPOSES) && - targetIsValid && - isMember(value.driver, WORKFLOW_DRIVERS) && - isMember(value.status, WORKFLOW_RUN_STATUSES) && - isNullableString(value.prompt) - ) -} - -function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { - if (storage === null) return [] - try { - const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) - if (serialized === null) return [] - const value: unknown = JSON.parse(serialized) - if ( - !isRecord(value) || - value.version !== WORKFLOW_RUN_STORAGE_VERSION || - !Array.isArray(value.runs) - ) { - return [] - } - return value.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) - } catch { - return [] - } -} - -function resolveBrowserStorage(): WorkflowRunStorage | null { - if (typeof window === 'undefined') return null - try { - return window.localStorage - } catch { - return null - } -} - -/** 内存快照是当前会话的权威状态,localStorage 仅用于刷新恢复。 */ -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() - - const snapshotList = () => [...runs.values()].map((run) => structuredClone(run)) - - return { - get(runId) { - const run = runs.get(runId) - return run ? structuredClone(run) : null - }, - list: snapshotList, - save(run) { - if (!isWorkflowRun(run)) throw new TypeError('Invalid WorkflowRun snapshot') - const saved = structuredClone(run) - runs.set(saved.id, saved) - - const persisted: PersistedWorkflowRuns = { - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [...runs.values()], - } - try { - storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) - } catch { - // 持久化失败不撤销已经写入的当前会话状态。 - } - - for (const listener of listeners.get(saved.id) ?? []) { - try { - listener(structuredClone(saved)) - } catch { - // 一个订阅方失败不能阻断其他订阅方。 - } - } - for (const listener of listListeners) { - try { - listener(snapshotList()) - } catch { - // 历史列表订阅方失败不影响已保存状态。 - } - } - }, - subscribe(runId, listener) { - const runListeners = listeners.get(runId) ?? new Set() - runListeners.add(listener) - listeners.set(runId, runListeners) - return () => { - runListeners.delete(listener) - if (runListeners.size === 0) listeners.delete(runId) - } - }, - subscribeAll(listener) { - listListeners.add(listener) - return () => listListeners.delete(listener) - }, - } -} diff --git a/frontend/src/entities/workflow-run/store/index.ts b/frontend/src/entities/workflow-run/store/index.ts new file mode 100644 index 00000000..75ca2234 --- /dev/null +++ b/frontend/src/entities/workflow-run/store/index.ts @@ -0,0 +1,14 @@ +/** + * WorkflowRun 本地仓库的子目录入口。 + * + * 本目录回答“WorkflowRun 在当前前端怎样创建、校验、保存和通知”。 + * 它依赖 model,但 model 不反向依赖 Store。后续接入服务器持久化时, + * 可替换这层的适配实现,不需改变 WorkflowRun 领域类型。 + */ + +export { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './workflow-run-store' diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts new file mode 100644 index 00000000..d1c53f5f --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -0,0 +1,439 @@ +/** + * WorkflowRun Store 的可执行业务规则。 + * + * 这些测试不是在验证页面点击,而是锁定数据层不得破坏的契约: + * 角色/动作任务的步骤必须分开,完成角色任务前必须有正式资产, + * 临时候选不得进入持久化快照,Revision 历史引用不得悬空。 + */ + +import { describe, expect, it, vi } from 'vitest' + +import type { WorkflowRevision, WorkflowRun, WorkflowRunPurpose, WorkflowStep } from '../model' +import { + createWorkflowRunStore, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './workflow-run-store' +import { CHARACTER_CANDIDATE_COUNT, WORKFLOW_STEP_ORDERS } from '../model/constants' + +/** 最小 localStorage 替身:既可观察序列化结果,也可主动模拟浏览器存储失败。 */ +class TestStorage { + value: string | null + failOnSet = false + + constructor(value: string | null = null) { + this.value = value + } + + getItem() { + return this.value + } + + setItem(_key: string, value: string) { + if (this.failOnSet) throw new Error('storage full') + this.value = value + } +} + +/** 根据 purpose 生成测试快照,避免测试自己重复写一套容易过期的步骤顺序。 */ +function createSteps( + prefix: string, + purpose: WorkflowRunPurpose = 'create_character', + activeIndex = 0, +): WorkflowStep[] { + return WORKFLOW_STEP_ORDERS[purpose].map((type, index) => ({ + id: `${prefix}:${type}`, + type, + status: index === activeIndex ? 'active' : 'locked', + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })) +} + +function createRevision( + id = 'revision-1', + purpose: WorkflowRunPurpose = 'create_character', +): WorkflowRevision { + return { + id, + basedOnRevisionId: null, + restartStepId: null, + status: 'active', + steps: createSteps(id, purpose), + generationStatus: 'not_started', + exportStatus: 'not_exported', + createdAt: '2026-08-03T00:00:00.000Z', + } +} + +function createRun(id = 'run-1'): WorkflowRun { + return { + id, + projectId: 'project-1', + characterId: null, + outfitId: null, + selectedAt: null, + purpose: 'create_character', + driver: 'ai', + status: 'active', + currentRevisionId: 'revision-1', + revisions: [createRevision()], + prompt: 'Create a hero', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + } +} + +function createAddActionRun(): WorkflowRun { + const base = createRun('run-add-action') + return { + id: base.id, + projectId: base.projectId, + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + driver: base.driver, + status: base.status, + currentRevisionId: base.currentRevisionId, + revisions: [createRevision('revision-1', 'add_action')], + prompt: 'Walk forward', + createdAt: base.createdAt, + updatedAt: base.updatedAt, + } +} + +/** 构造“从已通过步骤重做”的两版本历史,用于校验来源链。 */ +function createRunWithHistory(): WorkflowRun { + const first = createRevision() + first.status = 'abandoned' + first.steps = first.steps.map((step, index) => ({ + ...step, + status: index === 0 ? 'passed' : 'locked', + })) + const second = createRevision('revision-2') + second.basedOnRevisionId = first.id + second.restartStepId = first.steps[0]!.id + second.steps[0]!.referenceStepIds = [first.steps[0]!.id] + + return { + ...createRun(), + currentRevisionId: second.id, + revisions: [first, second], + } +} + +describe('createWorkflowRunStore', () => { + // 创建契约:同一界面可连续完成两任务,但底层必须创建两种不同步骤模板的 Run。 + it('creates a character task with only the character steps', () => { + const ids = ['run-1', 'revision-1', 'step-1', 'step-2', 'step-3'] + const store = createWorkflowRunStore({ + storage: null, + createId: () => ids.shift()!, + now: () => '2026-08-03T01:00:00.000Z', + }) + + const run = store.create({ + projectId: 'project-1', + purpose: 'create_character', + driver: 'ai', + prompt: ' Create a hero ', + }) + + expect(CHARACTER_CANDIDATE_COUNT).toBe(4) + expect(run).toMatchObject({ + id: 'run-1', + purpose: 'create_character', + characterId: null, + outfitId: null, + selectedAt: null, + prompt: 'Create a hero', + createdAt: '2026-08-03T01:00:00.000Z', + updatedAt: '2026-08-03T01:00:00.000Z', + }) + expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( + WORKFLOW_STEP_ORDERS.create_character, + ) + expect(run.revisions[0]?.steps.map((step) => step.status)).toEqual([ + 'active', + 'locked', + 'locked', + ]) + }) + + it('creates an action task only from an existing character and outfit', () => { + const ids = [ + 'run-2', + 'revision-2', + 'step-1', + 'step-2', + 'step-3', + 'step-4', + 'step-5', + 'step-6', + 'action-1', + ] + const store = createWorkflowRunStore({ + storage: null, + createId: () => ids.shift()!, + now: () => '2026-08-03T02:00:00.000Z', + }) + + const run = store.create({ + projectId: 'project-1', + purpose: 'add_action', + driver: 'manual', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + prompt: 'Walk forward', + }) + + expect(run).toMatchObject({ + purpose: 'add_action', + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '向前行走', + actionType: 'walk', + fps: 12, + }) + expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( + WORKFLOW_STEP_ORDERS.add_action, + ) + }) + + // 快照所有权契约:保存后修改原对象或查询结果,都不能绕过 Store 改写内存。 + it('persists versioned snapshots and returns defensive copies', () => { + const storage = new TestStorage() + const store = createWorkflowRunStore({ storage }) + const run = createRun() + + store.save(run) + run.prompt = 'changed outside' + const restored = store.get(run.id)! + restored.revisions[0]!.steps[0]!.status = 'failed' + + expect(store.get(run.id)?.prompt).toBe('Create a hero') + expect(store.get(run.id)?.revisions[0]?.steps[0]?.status).toBe('active') + expect(JSON.parse(storage.value!)).toEqual({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [createRun()], + }) + }) + + it('hydrates a valid revision history and exposes it through list', () => { + const run = createRunWithHistory() + const storage = new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), + ) + const store = createWorkflowRunStore({ storage }) + + expect(store.get(run.id)).toEqual(run) + expect(store.list()).toEqual([run]) + }) + + // 恢复边界采用严格白名单:坏 JSON、未知版本和断裂历史链都不得进入内存。 + it.each([ + ['invalid JSON', '{'], + ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], + [ + 'missing history source', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { ...createRunWithHistory().revisions[1], basedOnRevisionId: 'missing' }, + ], + }, + ], + }), + ], + [ + 'unknown referenced step', + JSON.stringify({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [ + { + ...createRunWithHistory(), + revisions: [ + createRunWithHistory().revisions[0], + { + ...createRunWithHistory().revisions[1], + steps: createRunWithHistory().revisions[1]!.steps.map((step, index) => + index === 0 ? { ...step, referenceStepIds: ['missing-step'] } : step, + ), + }, + ], + }, + ], + }), + ], + ])('ignores %s during hydration', (_label, serialized) => { + expect(createWorkflowRunStore({ storage: new TestStorage(serialized) }).list()).toEqual([]) + }) + + it('rejects invalid snapshots before they reach memory', () => { + const store = createWorkflowRunStore({ storage: null }) + const invalid = createRun() + invalid.revisions[0]!.steps[0]!.error = 'failed without failed status' + + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + expect(store.get(invalid.id)).toBeNull() + }) + + // 动作不是游离资产;它必须同时定位角色和具体造型。 + it('requires character and outfit references when adding an action', () => { + const valid = createAddActionRun() + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const invalid = { + ...valid, + characterId: null, + outfitId: null, + } as unknown as WorkflowRun + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + + const hydrated = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), + ), + }) + expect(hydrated.get(invalid.id)).toBeNull() + }) + + // 用户点选候选并不等于任务完成;必须等正式资产保存成功后再原子性填入三个字段。 + it('requires a saved asset and selection time before completing character creation', () => { + const valid = createRun() + valid.status = 'completed' + valid.characterId = 'character-1' + valid.outfitId = 'outfit-1' + valid.selectedAt = '2026-08-03T03:00:00.000Z' + valid.revisions[0]!.status = 'completed' + valid.revisions[0]!.steps = valid.revisions[0]!.steps.map((step) => ({ + ...step, + status: 'passed', + })) + const store = createWorkflowRunStore({ storage: null }) + + store.save(valid) + expect(store.get(valid.id)).toEqual(valid) + + const missingSelection = { ...valid, selectedAt: null } as unknown as WorkflowRun + expect(() => store.save(missingSelection)).toThrow('Invalid WorkflowRun snapshot') + }) + + // taskId 是追踪线索,不是仅在加载中存活的 UI 状态。 + it('retains a generation task id after its step passes', () => { + const run = createRun() + run.revisions[0]!.steps[0] = { + ...run.revisions[0]!.steps[0]!, + status: 'passed', + taskId: 'generation-1', + } + run.revisions[0]!.steps[1] = { + ...run.revisions[0]!.steps[1]!, + status: 'active', + } + const store = createWorkflowRunStore({ storage: null }) + + store.save(run) + + expect(store.get(run.id)?.revisions[0]?.steps[0]?.taskId).toBe('generation-1') + }) + + it('requires exactly four task ids before the first-frame batch can pass', () => { + const run = createAddActionRun() + const steps = run.revisions[0]!.steps + steps[0]!.status = 'passed' + steps[1]!.status = 'passed' + steps[1]!.candidateTaskIds = ['first-1', 'first-2', 'first-3'] + steps[2]!.status = 'active' + const store = createWorkflowRunStore({ storage: null }) + + expect(() => store.save(run)).toThrow('Invalid WorkflowRun snapshot') + + steps[1]!.candidateTaskIds.push('first-4') + store.save(run) + expect(store.get(run.id)?.revisions[0]?.steps[1]?.candidateTaskIds).toHaveLength(4) + }) + + // 四张候选属于临时缓存;运行历史只记录生成 taskId 和最终正式资产引用。 + it('rejects temporary candidate payloads in persisted workflow steps', () => { + const run = createRun() + const withCandidates = { + ...run, + revisions: [ + { + ...run.revisions[0], + steps: run.revisions[0]!.steps.map((step, index) => + index === 1 + ? { + ...step, + output: { + candidates: ['temporary-1', 'temporary-2', 'temporary-3', 'temporary-4'], + }, + } + : step, + ), + }, + ], + } as unknown as WorkflowRun + const store = createWorkflowRunStore({ storage: null }) + + expect(() => store.save(withCandidates)).toThrow('Invalid WorkflowRun snapshot') + }) + + // localStorage 失败不应让当前会话已完成的操作倒退,但刷新恢复能力会降级。 + it('keeps memory authoritative when persistence fails', () => { + const storage = new TestStorage() + storage.failOnSet = true + const store = createWorkflowRunStore({ storage }) + + expect(() => store.save(createRun())).not.toThrow() + expect(store.get('run-1')).toEqual(createRun()) + }) + + it('notifies run and history subscribers without sharing mutable values', () => { + const store = createWorkflowRunStore({ storage: null }) + const runListener = vi.fn((run: WorkflowRun) => { + run.prompt = 'listener mutation' + }) + const listListener = vi.fn() + const unsubscribeRun = store.subscribe('run-1', runListener) + const unsubscribeAll = store.subscribeAll(listListener) + + store.save(createRun()) + + expect(store.get('run-1')?.prompt).toBe('Create a hero') + expect(listListener).toHaveBeenCalledWith([createRun()]) + unsubscribeRun() + unsubscribeAll() + store.save({ ...createRun(), prompt: 'second save' }) + expect(runListener).toHaveBeenCalledTimes(1) + expect(listListener).toHaveBeenCalledTimes(1) + }) + + it('uses the stable browser storage key', () => { + const setItem = vi.fn() + const store = createWorkflowRunStore({ storage: { getItem: () => null, setItem } }) + + store.save(createRun()) + + expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + }) +}) diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts new file mode 100644 index 00000000..438be22c --- /dev/null +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -0,0 +1,465 @@ +/** + * WorkflowRun 的本地仓库与运行时边界校验。 + * + * 这个 Store 只做四件事:创建合法初始快照、保存/读取快照、刷新恢复、通知订阅者。 + * 它不是 WorkflowController:不调 Generation API、不处理 SSE、不决定何时进入下一步, + * 也不负责调用后端候选图清理接口。这些编排行为由同一 Entity 下的 + * WorkflowRun Service 组合已有 Generation/Character 端口完成。 + * + * localStorage 是当前没有 WorkflowRun 后端持久化时的刷新恢复适配器, + * 不代表把浏览器宣布为最终服务器数据源。 + */ + +import type { + CreateWorkflowRunInput, + WorkflowRevision, + WorkflowRun, + WorkflowRunPurpose, +} from '../model' +import { + ACTION_FIRST_FRAME_CANDIDATE_COUNT, + EXPORT_STATUSES, + GENERATION_STATUSES, + WORKFLOW_DRIVERS, + WORKFLOW_PURPOSES, + WORKFLOW_REVISION_STATUSES, + WORKFLOW_RUN_STATUSES, + WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_STATUSES, +} from '../model/constants' + +/** 稳定 key 保证刷新前后读取同一份数据,不随页面路由或组件名改动。 */ +export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' + +/** + * 持久化数据版本。当快照结构或业务不变式变更时递增, + * 防止新代码将旧 JSON 误认为合法运行状态。 + */ +export const WORKFLOW_RUN_STORAGE_VERSION = 3 + +type WorkflowRunListener = (run: WorkflowRun) => void +type WorkflowRunListListener = (runs: WorkflowRun[]) => void + +/** 只依赖最小存储能力,测试可用内存替身,未来也可换成其他适配器。 */ +interface WorkflowRunStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** + * WorkflowRun 的最小仓库接口。 + * + * create 只产生初始合法快照;save 保存已由业务层推进的整体快照。 + * subscribe 服务单个创作页,subscribeAll 服务历史列表;两者都不改写数据。 + */ +export interface WorkflowRunStore { + create(input: CreateWorkflowRunInput): WorkflowRun + get(runId: WorkflowRun['id']): WorkflowRun | null + list(): WorkflowRun[] + save(run: WorkflowRun): void + subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void + subscribeAll(listener: WorkflowRunListListener): () => void +} + +export interface CreateWorkflowRunStoreOptions { + /** null 表示仅保存在当前内存;未传时浏览器默认使用 localStorage。 */ + storage?: WorkflowRunStorage | null + /** 测试可注入确定性 ID;生产默认使用 crypto.randomUUID。 */ + createId?: () => string + /** 测试可注入确定性时间。 */ + now?: () => string +} + +interface PersistedWorkflowRuns { + version: typeof WORKFLOW_RUN_STORAGE_VERSION + runs: WorkflowRun[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isMember(value: unknown, members: readonly T[]): value is T { + return typeof value === 'string' && members.includes(value as T) +} + +/** + * 校验单个步骤的关键不变式。 + * + * - failed 必须有可读错误,非 failed 不得残留旧错误; + * - submissionId 只能出现在 active 且与 taskId 互斥; + * - taskId 可在 passed/failed 后保留,便于追踪后端任务; + * - 只有 first-frame 可保存最多 4 个 candidateTaskIds,passed 时必须已集齐 4 个; + * - 拒绝 input/output 是为了防止四张临时候选或页面对象被塞进长期快照。 + */ +function isWorkflowStep(value: unknown, expectedType: string): boolean { + if (!isRecord(value)) return false + const candidateTaskIds = isStringArray(value.candidateTaskIds) ? value.candidateTaskIds : null + + const errorIsValid = + isNullableString(value.error) && + (value.status === 'failed' + ? typeof value.error === 'string' && value.error.trim().length > 0 + : value.error === null) + const taskStateIsValid = + isNullableString(value.taskId) && + candidateTaskIds !== null && + new Set(candidateTaskIds).size === candidateTaskIds.length && + candidateTaskIds.every((id) => id.length > 0) && + isNullableString(value.submissionId) && + !(value.taskId !== null && value.submissionId !== null) && + (value.submissionId === null || value.status === 'active') && + (value.taskId === null || ['active', 'passed', 'failed'].includes(String(value.status))) + const candidateTasksAreValid = + candidateTaskIds !== null && + (expectedType === 'first-frame' + ? value.taskId === null && + candidateTaskIds.length <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && + (value.status !== 'passed' || + candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) + : candidateTaskIds.length === 0) + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + value.type === expectedType && + isMember(value.status, WORKFLOW_STEP_STATUSES) && + !('input' in value) && + !('output' in value) && + taskStateIsValid && + candidateTasksAreValid && + errorIsValid && + isStringArray(value.referenceStepIds) + ) +} + +/** + * Revision 必须完整包含当前 purpose 的步骤模板,数量、顺序和 type 都要一致。 + * 这会阻止 add_action 在恢复时被误塞入角色母版步骤,也阻止页面自行改变顺序。 + */ +function isWorkflowRevision( + value: unknown, + purpose: WorkflowRunPurpose, +): value is WorkflowRevision { + if (!isRecord(value) || !Array.isArray(value.steps)) return false + const stepIds = value.steps.map((step) => (isRecord(step) ? step.id : null)) + const expectedOrder = WORKFLOW_STEP_ORDERS[purpose] + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNullableString(value.basedOnRevisionId) && + isNullableString(value.restartStepId) && + isMember(value.status, WORKFLOW_REVISION_STATUSES) && + value.steps.length === expectedOrder.length && + value.steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) && + new Set(stepIds).size === stepIds.length && + isMember(value.generationStatus, GENERATION_STATUSES) && + isMember(value.exportStatus, EXPORT_STATUSES) && + typeof value.createdAt === 'string' + ) +} + +/** + * 验证 Revision 历史链,防止伪造或悬空引用。 + * + * 首版不能有来源;后续版本必须指向更早的 Revision,且只能从该版本中 + * 已 passed 的步骤重开。referenceStepIds 只能引用已经出现的旧步骤, + * 不能指向未来版本或不存在的 ID。 + */ +function hasValidRevisionLine(revisions: WorkflowRevision[]): boolean { + const prior = new Map() + const priorStepIds = new Set() + + for (const [index, revision] of revisions.entries()) { + if (prior.has(revision.id)) return false + if (index === 0) { + if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false + } else { + if (revision.basedOnRevisionId === null || revision.restartStepId === null) return false + const source = prior.get(revision.basedOnRevisionId) + if ( + !source?.steps.some( + (step) => step.id === revision.restartStepId && step.status === 'passed', + ) + ) { + return false + } + } + if ( + revision.steps.some((step) => + step.referenceStepIds.some((stepId) => !priorStepIds.has(stepId)), + ) + ) { + return false + } + prior.set(revision.id, revision) + revision.steps.forEach((step) => priorStepIds.add(step.id)) + } + + return true +} + +/** + * 整体 Run 校验。它在两个不可信边界调用:读取 localStorage 和 save() 写入前。 + * 因此 TypeScript 类型正确仍不够;JSON、旧版数据和手工断言都可能绕过编译期。 + */ +function isWorkflowRun(value: unknown): value is WorkflowRun { + if ( + !isRecord(value) || + !isMember(value.purpose, WORKFLOW_PURPOSES) || + !Array.isArray(value.revisions) || + value.revisions.length === 0 + ) { + return false + } + const purpose = value.purpose + if (!value.revisions.every((revision) => isWorkflowRevision(revision, purpose))) return false + + const revisions = value.revisions + const current = revisions.at(-1) + if (!current || current.id !== value.currentRevisionId || !hasValidRevisionLine(revisions)) { + return false + } + + // Run 的结果必须与当前 Revision 结果同步,避免页面各读一层时得到矛盾答案。 + const expectedRevisionStatus = + value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' + if (current.status !== expectedRevisionStatus) return false + if (revisions.slice(0, -1).some((revision) => revision.status === 'active')) return false + + // 运行中/已中断保留唯一当前步骤;终态不得继续挂着 active 步骤。 + const activeStepCount = current.steps.filter((step) => step.status === 'active').length + if ( + ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || + ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + ) { + return false + } + + // + // 角色 Run 有两个合法阶段:尚未选择时三个字段都为 null, + // 或正式保存成功后 ID 与选择时间同时存在。动作 Run 则从创建起就必须绑定角色造型。 + const targetIsValid = + value.purpose === 'add_action' + ? isNonEmptyString(value.characterId) && + isNonEmptyString(value.outfitId) && + value.selectedAt === undefined && + isNonEmptyString(value.actionId) && + isNonEmptyString(value.actionName) && + ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType)) && + typeof value.fps === 'number' && + Number.isFinite(value.fps) && + value.fps > 0 + : value.purpose === 'create_character' && + ((value.characterId === null && value.outfitId === null && value.selectedAt === null) || + (isNonEmptyString(value.characterId) && + isNonEmptyString(value.outfitId) && + isNonEmptyString(value.selectedAt))) + + if ( + value.purpose === 'create_character' && + value.status === 'completed' && + value.characterId === null + ) { + return false + } + + return ( + typeof value.id === 'string' && + value.id.length > 0 && + isNonEmptyString(value.projectId) && + targetIsValid && + isMember(value.driver, WORKFLOW_DRIVERS) && + isMember(value.status, WORKFLOW_RUN_STATUSES) && + isNullableString(value.prompt) && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.updatedAt) + ) +} + +/** + * 持久化读取采用“失败即忽略”策略:一条损坏数据不能阻止应用启动。 + * 这不是默默修复错误;无法证明合法的 Run 不进入内存,避免错误状态被继续推进。 + */ +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { + if (storage === null) return [] + try { + const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (serialized === null) return [] + const value: unknown = JSON.parse(serialized) + if ( + !isRecord(value) || + value.version !== WORKFLOW_RUN_STORAGE_VERSION || + !Array.isArray(value.runs) + ) { + return [] + } + return value.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + } catch { + return [] + } +} + +/** SSR/测试环境没有 window,隐私模式也可能拒绝 localStorage,因此存储能力必须可降级。 */ +function resolveBrowserStorage(): WorkflowRunStorage | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage + } catch { + return null + } +} + +/** 运行、版本和步骤都要跨刷新稳定引用,所以不使用数组下标或时间戳充当 ID。 */ +function createRandomId(): string { + if (typeof globalThis.crypto?.randomUUID !== 'function') { + throw new Error('crypto.randomUUID is required to create a WorkflowRun') + } + return globalThis.crypto.randomUUID() +} + +/** + * 创建 WorkflowRun Store。 + * + * 内存快照是当前会话的权威状态,localStorage 仅用于刷新恢复。 + * 所以存储写入失败时不回滚内存:用户当前页面仍可继续工作, + * 但刷新恢复能力已降级。未来接入后端持久化时,应替换适配器而不改变业务模型。 + */ +export function createWorkflowRunStore( + options: CreateWorkflowRunStoreOptions = {}, +): WorkflowRunStore { + const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage + const createId = options.createId ?? createRandomId + const now = options.now ?? (() => new Date().toISOString()) + const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + const listeners = new Map>() + const listListeners = new Set() + + const snapshotList = () => [...runs.values()].map((run) => structuredClone(run)) + + const store: WorkflowRunStore = { + create(input) { + // create 只在用户真正发起任务时调用: + // 选好角色后进入动作区域不创建空 Run,点击“生成动作”才创建 add_action。 + const createdAt = now() + const runId = createId() + const revisionId = createId() + // 初始时只激活第一步,后续步骤等待前置条件通过。 + const steps = WORKFLOW_STEP_ORDERS[input.purpose].map((type, index) => ({ + id: createId(), + type, + status: index === 0 ? ('active' as const) : ('locked' as const), + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + })) + const base = { + id: runId, + projectId: input.projectId, + purpose: input.purpose, + driver: input.driver, + status: 'active' as const, + currentRevisionId: revisionId, + revisions: [ + { + id: revisionId, + basedOnRevisionId: null, + restartStepId: null, + status: 'active' as const, + steps, + generationStatus: 'not_started' as const, + exportStatus: 'not_exported' as const, + createdAt, + }, + ], + prompt: input.prompt?.trim() || null, + createdAt, + updatedAt: createdAt, + } + const run: WorkflowRun = + input.purpose === 'create_character' + ? { ...base, purpose: input.purpose, characterId: null, outfitId: null, selectedAt: null } + : { + ...base, + purpose: input.purpose, + characterId: input.characterId, + outfitId: input.outfitId, + actionId: createId(), + actionName: input.actionName.trim(), + actionType: input.actionType, + fps: input.fps, + } + + // 统一走 save 以复用运行时校验、持久化和订阅通知,避免 create 产生特例状态。 + store.save(run) + return structuredClone(run) + }, + get(runId) { + const run = runs.get(runId) + return run ? structuredClone(run) : null + }, + list: snapshotList, + save(run) { + if (!isWorkflowRun(run)) throw new TypeError('Invalid WorkflowRun snapshot') + // 内外都使用深拷贝,防止调用方在 save/get 后继续修改对象,绕过校验篡改 Store。 + const saved = structuredClone(run) + runs.set(saved.id, saved) + + const persisted: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + try { + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) + } catch { + // 持久化失败不撤销已经写入的当前会话状态,只降级刷新恢复能力。 + } + + for (const listener of listeners.get(saved.id) ?? []) { + try { + // 每个订阅者获得独立副本,一个页面不能通过修改参数影响另一个页面。 + listener(structuredClone(saved)) + } catch { + // 一个订阅方失败不能阻断其他订阅方。 + } + } + for (const listener of listListeners) { + try { + listener(snapshotList()) + } catch { + // 历史列表订阅方失败不影响已保存状态。 + } + } + }, + subscribe(runId, listener) { + const runListeners = listeners.get(runId) ?? new Set() + runListeners.add(listener) + listeners.set(runId, runListeners) + return () => { + runListeners.delete(listener) + if (runListeners.size === 0) listeners.delete(runId) + } + }, + subscribeAll(listener) { + listListeners.add(listener) + return () => listListeners.delete(listener) + }, + } + + return store +} From c49e696097d6a6283c2a6f04bf333875d9b487da Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:43:26 +0800 Subject: [PATCH 05/27] feat(workflow-run): expose action review frames --- frontend/src/entities/index.ts | 2 + frontend/src/entities/workflow-run/index.ts | 2 + .../entities/workflow-run/service/index.ts | 2 + .../service/workflow-run-service.test.ts | 27 ++++++++++++ .../service/workflow-run-service.ts | 44 +++++++++++++++++-- 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 1075fc24..bf05b437 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -74,6 +74,8 @@ export { } from './workflow-run' export type { ActionFirstFrameCandidateBatch, + ActionReviewFrame, + ActionReviewResult, CreateWorkflowRunStoreOptions, CreateWorkflowRunServiceOptions, CreateWorkflowRunInput, diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 2ae8a5b9..00d9292e 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -29,6 +29,8 @@ export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' export { createWorkflowRunService } from './service' export type { ActionFirstFrameCandidateBatch, + ActionReviewFrame, + ActionReviewResult, CharacterCandidateBatch, CharacterCandidateConfirmationApis, ConfirmCharacterSelectionInput, diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts index 72bf39b1..4b530d36 100644 --- a/frontend/src/entities/workflow-run/service/index.ts +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -8,6 +8,8 @@ export { createWorkflowRunService } from './workflow-run-service' export type { ActionFirstFrameCandidateBatch, + ActionReviewFrame, + ActionReviewResult, CharacterCandidateBatch, CharacterCandidateConfirmationApis, ConfirmCharacterSelectionInput, diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index 305a7b7f..ad6441a9 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -186,6 +186,14 @@ describe('createWorkflowRunService', () => { ) expect(generation.create).toHaveBeenCalledTimes(6) + const review = await service.getActionReview(actionRun.id) + expect(review).toEqual({ + run: actionRun, + generationId: 'generation-6', + frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], + }) + expect(store.get(actionRun.id)).toEqual(actionRun) + const published = await service.approveAction(actionRun.id) expect(published.run.status).toBe('completed') expect(published.actionId).toBe(actionRun.actionId) @@ -293,12 +301,31 @@ describe('createWorkflowRunService', () => { expect(characterApis.get).toHaveBeenCalledTimes(2) const resumed = await service.resumeAction(actionRun.id) + const review = await service.getActionReview(actionRun.id) expect(resumed).toEqual(actionRun) + expect(review.frames).toEqual([{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }]) expect(generation.create).toHaveBeenCalledTimes(5) expect(characterApis.get).toHaveBeenCalledTimes(2) }) + it('does not expose animation frames before the action reaches review', async () => { + const { service } = createService() + const firstFrames = await service.startAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + driver: 'ai', + }) + + await expect(service.getActionReview(firstFrames.run.id)).rejects.toThrow( + '动作尚未进入可审核状态', + ) + }) + it('rejects a first-frame image that is not one of the four current candidates', async () => { const { service, generation } = createService() const firstFrames = await service.startAction({ diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 3bd51257..e5496c21 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -79,6 +79,29 @@ export interface ConfirmActionFirstFrameInput { selectedImageUrl: string } +/** + * 动作审核页真正需要的一帧。 + * + * 这里不直接把 Generation 的 DTO 暴露给页面:Generation 负责描述后端任务结果, + * WorkflowRun 则只交付当前任务已经确认可用于审核的图片地址。 + */ +export interface ActionReviewFrame { + imageUrl: string +} + +/** + * 完整动画生成结束后的只读审核结果。 + * + * generationId 让调用方能够定位本次完整动画任务;frames 的数组顺序就是播放顺序。 + * 读取该结果不会修改 Run,也不会把临时图片 URL 写进 WorkflowRun 快照。 + */ +export interface ActionReviewResult { + /** 审核结果只可能属于动作任务,调用方无需再次判断 purpose。 */ + run: Extract + generationId: string + frames: readonly ActionReviewFrame[] +} + export interface PublishActionResult { run: WorkflowRun character: Character @@ -95,6 +118,7 @@ export interface WorkflowRunService { resumeActionFirstFrameCandidates(runId: string): Promise confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise resumeAction(runId: string): Promise + getActionReview(runId: string): Promise approveAction(runId: string): Promise } @@ -388,7 +412,7 @@ export function createWorkflowRunService({ } } - async function approveAction(runId: string): Promise { + async function getActionReview(runId: string): Promise { const run = requireRun(store, runId) if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') if (run.status !== 'active' || requireStep(run, 'review').status !== 'active') { @@ -400,6 +424,19 @@ export function createWorkflowRunService({ await generationApis.get(run.projectId, animationStep.taskId), ) + return { + run, + generationId: animationStep.taskId, + frames: animation.frames.map((frame) => ({ imageUrl: frame.url })), + } + } + + async function approveAction(runId: string): Promise { + // 审核页展示和最终写入角色必须读取同一份、经过同一套校验的动画结果。 + // 这样可以避免页面看见一组帧,点击通过后却导入另一组帧。 + const review = await getActionReview(runId) + const run = review.run + const character = await characterApis.get(run.characterId) const outfit = character.outfits.find((item) => item.id === run.outfitId) if (!outfit) throw new Error('动作所属造型不存在') @@ -411,8 +448,8 @@ export function createWorkflowRunService({ type: run.actionType, fps: run.fps, keyFrameIndex: null, - frames: animation.frames.map((frame) => ({ - imageUrl: frame.url, + frames: review.frames.map((frame) => ({ + imageUrl: frame.imageUrl, durationMs: null, rootMotion: null, })), @@ -459,6 +496,7 @@ export function createWorkflowRunService({ resumeActionFirstFrameCandidates, confirmActionFirstFrame, resumeAction, + getActionReview, approveAction, } } From e4a1f013d6222867dc29525617e923a30dfe10df Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:16:31 +0800 Subject: [PATCH 06/27] fix(workflow-run): support interrupted task recovery --- .../service/workflow-run-service.test.ts | 24 +++++++++++++ .../service/workflow-run-service.ts | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index ad6441a9..af18dc50 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -239,6 +239,30 @@ describe('createWorkflowRunService', () => { expect(fixture.store.get(batch.run.id)?.status).toBe('active') }) + it('interrupts an active run and continues it without changing the active step', async () => { + const { service, store } = createService() + const batch = await service.startCharacter({ + projectId: 'project-1', + prompt: '角色', + driver: 'ai', + }) + const activeStepId = batch.run.revisions[0]?.steps.find((step) => step.status === 'active')?.id + + const interrupted = service.interruptRun(batch.run.id) + expect(interrupted.status).toBe('interrupted') + expect(interrupted.revisions[0]?.steps.find((step) => step.status === 'active')?.id).toBe( + activeStepId, + ) + expect(store.get(batch.run.id)?.status).toBe('interrupted') + + const resumed = service.continueRun(batch.run.id) + expect(resumed.status).toBe('active') + expect(resumed.revisions[0]?.steps.find((step) => step.status === 'active')?.id).toBe( + activeStepId, + ) + expect(store.get(batch.run.id)?.status).toBe('active') + }) + it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { const { service, store, generation } = createService() const run = store.create({ diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index e5496c21..09c4e426 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -111,6 +111,10 @@ export interface PublishActionResult { } export interface WorkflowRunService { + /** 暂停进行中的 Run;当前 Revision 和 active 步骤保持不变。 */ + interruptRun(runId: string): WorkflowRun + /** 将已暂停的 Run 恢复为可执行状态;对 active Run 幂等。 */ + continueRun(runId: string): WorkflowRun startCharacter(input: StartCharacterRunInput): Promise resumeCharacterCandidates(runId: string): Promise confirmCharacter(input: ConfirmCharacterSelectionInput): Promise @@ -137,6 +141,34 @@ export function createWorkflowRunService({ candidateConfirmationApis, now = () => new Date().toISOString(), }: CreateWorkflowRunServiceOptions): WorkflowRunService { + function interruptRun(runId: string): WorkflowRun { + const run = requireRun(store, runId) + if (run.status === 'interrupted') return run + if (run.status !== 'active') throw new Error('只有进行中的 WorkflowRun 可以中断') + + const interrupted: WorkflowRun = { + ...run, + status: 'interrupted', + updatedAt: now(), + } + store.save(interrupted) + return interrupted + } + + function continueRun(runId: string): WorkflowRun { + const run = requireRun(store, runId) + if (run.status === 'active') return run + if (run.status !== 'interrupted') throw new Error('只有已中断的 WorkflowRun 可以继续') + + const active: WorkflowRun = { + ...run, + status: 'active', + updatedAt: now(), + } + store.save(active) + return active + } + async function startCharacter(input: StartCharacterRunInput): Promise { const prompt = input.prompt.trim() if (!prompt) throw new Error('请先描述想要创建的角色') @@ -489,6 +521,8 @@ export function createWorkflowRunService({ } return { + interruptRun, + continueRun, startCharacter, resumeCharacterCandidates, confirmCharacter, From 8762df0e5a38e1c71b6735cd825a599d3621f4c3 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:53:41 +0800 Subject: [PATCH 07/27] feat(workflow-controller): add workflow coordination boundary --- .../workflow-controller/controller.test.ts | 206 ++++++++++++++++++ .../workflow-controller/controller.ts | 147 +++++++++++++ .../src/features/workflow-controller/index.ts | 71 +----- 3 files changed, 361 insertions(+), 63 deletions(-) create mode 100644 frontend/src/features/workflow-controller/controller.test.ts create mode 100644 frontend/src/features/workflow-controller/controller.ts diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts new file mode 100644 index 00000000..9f6233bc --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -0,0 +1,206 @@ +/** WorkflowController 只测协调边界,WorkflowRun Service 内部流程由 Entity 自己的测试保护。 */ + +import { describe, expect, it, vi } from 'vitest' + +import type { + ActionFirstFrameCandidateBatch, + CharacterCandidateBatch, + WorkflowRun, + WorkflowRunService, + WorkflowRunStore, + WorkflowStepType, +} from '@/entities' +import { createWorkflowController } from './controller' + +function createRun( + purpose: 'create_character' | 'add_action', + activeType: WorkflowStepType, + status: WorkflowRun['status'] = 'active', +): WorkflowRun { + const base = { + id: `run-${purpose}`, + projectId: 'project-1', + purpose, + driver: 'ai' as const, + status, + currentRevisionId: 'revision-1', + revisions: [ + { + id: 'revision-1', + basedOnRevisionId: null, + restartStepId: null, + status: status === 'completed' ? ('completed' as const) : ('active' as const), + steps: [ + { + id: 'step-1', + type: activeType, + status: status === 'active' ? ('active' as const) : ('passed' as const), + taskId: null, + candidateTaskIds: [], + submissionId: null, + error: null, + referenceStepIds: [], + }, + ], + generationStatus: 'not_started' as const, + exportStatus: 'not_exported' as const, + createdAt: '2026-08-03T00:00:00.000Z', + }, + ], + prompt: '角色', + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', + } + return ( + purpose === 'create_character' + ? { ...base, purpose, characterId: null, outfitId: null, selectedAt: null } + : { + ...base, + purpose, + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + } + ) as WorkflowRun +} + +function createFixture(initialRuns: WorkflowRun[] = []) { + const runs = new Map(initialRuns.map((run) => [run.id, run])) + const store: WorkflowRunStore = { + create: vi.fn(), + get: vi.fn((runId) => runs.get(runId) ?? null), + list: vi.fn(() => [...runs.values()]), + save: vi.fn(), + subscribe: vi.fn(() => () => undefined), + subscribeAll: vi.fn(() => () => undefined), + } + const service = { + startCharacter: vi.fn(), + resumeCharacterCandidates: vi.fn(), + confirmCharacter: vi.fn(), + startAction: vi.fn(), + resumeActionFirstFrameCandidates: vi.fn(), + confirmActionFirstFrame: vi.fn(), + resumeAction: vi.fn(), + approveAction: vi.fn(), + } as unknown as WorkflowRunService + return { controller: createWorkflowController({ store, service }), store, service } +} + +describe('createWorkflowController', () => { + it('delegates business commands to WorkflowRun Service without saving snapshots itself', async () => { + const { controller, store, service } = createFixture() + const characterInput = { projectId: 'project-1', prompt: '角色', driver: 'ai' as const } + const actionInput = { + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk' as const, + fps: 12, + driver: 'ai' as const, + } + + await controller.startCharacter(characterInput) + await controller.confirmCharacter({ runId: 'character-run', selectedImageUrl: 'character.png' }) + await controller.startAction(actionInput) + await controller.confirmActionFirstFrame({ + runId: 'action-run', + selectedImageUrl: 'first-frame.png', + }) + await controller.approveAction('action-run') + + expect(service.startCharacter).toHaveBeenCalledWith(characterInput) + expect(service.confirmCharacter).toHaveBeenCalledWith({ + runId: 'character-run', + selectedImageUrl: 'character.png', + }) + expect(service.startAction).toHaveBeenCalledWith(actionInput) + expect(service.confirmActionFirstFrame).toHaveBeenCalledWith({ + runId: 'action-run', + selectedImageUrl: 'first-frame.png', + }) + expect(service.approveAction).toHaveBeenCalledWith('action-run') + expect(store.save).not.toHaveBeenCalled() + }) + + it('restores character candidates through the character resume use case', async () => { + const run = createRun('create_character', 'character-template') + const batch: CharacterCandidateBatch = { + run, + generationId: 'generation-1', + candidates: ['a.png', 'b.png', 'c.png', 'd.png'], + } + const { controller, service } = createFixture([run]) + vi.mocked(service.resumeCharacterCandidates).mockResolvedValue(batch) + + await expect(controller.resume(run.id)).resolves.toEqual({ + phase: 'character-candidates', + ...batch, + }) + }) + + it('restores action first-frame candidates without starting complete animation', async () => { + const run = createRun('add_action', 'first-frame-candidate') + const batch: ActionFirstFrameCandidateBatch = { + run, + candidateTaskIds: ['first-1', 'first-2', 'first-3', 'first-4'], + candidates: ['a.png', 'b.png', 'c.png', 'd.png'], + } + const { controller, service } = createFixture([run]) + vi.mocked(service.resumeActionFirstFrameCandidates).mockResolvedValue(batch) + + const snapshot = await controller.resume(run.id) + + expect(snapshot).toEqual({ phase: 'action-first-frame-candidates', ...batch }) + expect(service.resumeAction).not.toHaveBeenCalled() + }) + + it('resumes complete animation through Service and returns the review phase', async () => { + const generating = createRun('add_action', 'complete-animation') + const reviewing = createRun('add_action', 'review') + const { controller, service } = createFixture([generating]) + vi.mocked(service.resumeAction).mockResolvedValue(reviewing) + + await expect(controller.resume(generating.id)).resolves.toEqual({ + phase: 'action-review', + run: reviewing, + }) + }) + + it('returns setup and terminal snapshots without invoking generation recovery', async () => { + const setup = createRun('create_character', 'character-setup') + const completed = createRun('add_action', 'review', 'completed') + const { controller, service } = createFixture([setup, completed]) + + await expect(controller.resume(setup.id)).resolves.toEqual({ + phase: 'character-setup', + run: setup, + }) + await expect(controller.resume(completed.id)).resolves.toEqual({ + phase: 'terminal', + run: completed, + }) + expect(service.resumeCharacterCandidates).not.toHaveBeenCalled() + expect(service.resumeAction).not.toHaveBeenCalled() + }) + + it('delegates reads, project filtering and subscriptions to Store', () => { + const first = createRun('create_character', 'character-setup') + const second = { ...createRun('add_action', 'action-setup'), projectId: 'project-2' } + const { controller, store } = createFixture([first, second]) + const listener = vi.fn() + const listListener = vi.fn() + + expect(controller.getWorkflow(first.id)).toBe(first) + expect(controller.listWorkflows('project-1')).toEqual([first]) + controller.subscribe(first.id, listener) + controller.subscribeAll(listListener) + + expect(store.subscribe).toHaveBeenCalledWith(first.id, listener) + expect(store.subscribeAll).toHaveBeenCalledWith(listListener) + }) +}) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts new file mode 100644 index 00000000..5911e6d4 --- /dev/null +++ b/frontend/src/features/workflow-controller/controller.ts @@ -0,0 +1,147 @@ +/** + * 创作页面与 WorkflowRun Entity 之间的协调层。 + * + * WorkflowRun Service 已经负责步骤迁移、Generation 调用和 Character 写入; + * Controller 只把这些用例整理成页面命令,并在刷新时根据当前步骤选择 + * 正确的恢复入口。它不拥有第二份流程状态,也不直接调用 store.save()。 + */ + +import type { + ActionFirstFrameCandidateBatch, + CharacterCandidateBatch, + ConfirmActionFirstFrameInput, + ConfirmCharacterSelectionInput, + PublishActionResult, + StartActionRunInput, + StartCharacterRunInput, + WorkflowRun, + WorkflowRunService, + WorkflowRunStore, + WorkflowStep, +} from '@/entities' + +/** + * 页面恢复结果。 + * + * 候选阶段携带当次从后端取回的临时 URL;其他阶段只携带 Run。 + * 页面用 phase 选择界面,无需自己解释步骤顺序或后端任务状态。 + */ +export type WorkflowControllerSnapshot = + | { phase: 'character-setup'; run: WorkflowRun } + | ({ phase: 'character-candidates' } & CharacterCandidateBatch) + | { phase: 'action-setup'; run: WorkflowRun } + | ({ phase: 'action-first-frame-candidates' } & ActionFirstFrameCandidateBatch) + | { phase: 'action-review'; run: WorkflowRun } + | { phase: 'terminal'; run: WorkflowRun } + +export interface WorkflowController { + /** 开始角色任务,完成后返回 4 张角色候选。 */ + startCharacter(input: StartCharacterRunInput): Promise + /** 确认角色候选;正式保存成功后角色 Run 完成。 */ + confirmCharacter(input: ConfirmCharacterSelectionInput): Promise + /** 用户点击生成动作时创建独立 Run,返回 4 张动作首帧候选。 */ + startAction(input: StartActionRunInput): Promise + /** 选中 1 张首帧后生成完整动画,直到进入审核。 */ + confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise + /** 审核通过后写入 Character,返回导入 Playtest 所需的稳定 ID。 */ + approveAction(runId: WorkflowRun['id']): Promise + + /** 按 ID 读取防御性快照;不存在时返回 null。 */ + getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null + /** 列出全部 Run,可选按项目过滤,供后续历史页使用。 */ + listWorkflows(projectId?: string): WorkflowRun[] + /** 订阅单个 Run;Controller 不额外缓存副本。 */ + subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void + /** 订阅列表变化,供后续项目/历史视图复用。 */ + subscribeAll(listener: (runs: WorkflowRun[]) => void): () => void + + /** + * 页面刷新或路由重进时的唯一恢复入口。 + * Controller 只分流,真正的 taskId 查询、订阅和结果校验由 Service 完成。 + */ + resume(runId: WorkflowRun['id']): Promise +} + +export interface CreateWorkflowControllerOptions { + /** 当前 WorkflowRun 快照的统一读取边界;Controller 只读取和订阅。 */ + store: WorkflowRunStore + /** 作为唯一业务写入入口,Controller 不复制其逻辑。 */ + service: WorkflowRunService +} + +export function createWorkflowController({ + store, + service, +}: CreateWorkflowControllerOptions): WorkflowController { + function getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null { + return store.get(runId) + } + + function listWorkflows(projectId?: string): WorkflowRun[] { + const runs = store.list() + return projectId === undefined ? runs : runs.filter((run) => run.projectId === projectId) + } + + function subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void) { + return store.subscribe(runId, listener) + } + + function subscribeAll(listener: (runs: WorkflowRun[]) => void) { + return store.subscribeAll(listener) + } + + async function resume(runId: WorkflowRun['id']): Promise { + const run = store.get(runId) + if (!run) return null + if (run.status !== 'active') return { phase: 'terminal', run } + + const activeStep = getActiveStep(run) + if (run.purpose === 'create_character') { + if (activeStep.type === 'character-setup') return { phase: 'character-setup', run } + if (activeStep.type !== 'character-template' && activeStep.type !== 'template-candidate') { + throw new Error(`角色 WorkflowRun 无法恢复未知步骤:${activeStep.type}`) + } + const batch = await service.resumeCharacterCandidates(run.id) + return { phase: 'character-candidates', ...batch } + } + + if (activeStep.type === 'action-setup') return { phase: 'action-setup', run } + if (activeStep.type === 'first-frame' || activeStep.type === 'first-frame-candidate') { + const batch = await service.resumeActionFirstFrameCandidates(run.id) + return { phase: 'action-first-frame-candidates', ...batch } + } + if (activeStep.type === 'complete-animation') { + return toActionSnapshot(await service.resumeAction(run.id)) + } + if (activeStep.type === 'review') return { phase: 'action-review', run } + throw new Error(`动作 WorkflowRun 无法恢复未知步骤:${activeStep.type}`) + } + + return { + startCharacter: (input) => service.startCharacter(input), + confirmCharacter: (input) => service.confirmCharacter(input), + startAction: (input) => service.startAction(input), + confirmActionFirstFrame: (input) => service.confirmActionFirstFrame(input), + approveAction: (runId) => service.approveAction(runId), + getWorkflow, + listWorkflows, + subscribe, + subscribeAll, + resume, + } +} + +function getActiveStep(run: WorkflowRun): WorkflowStep { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) + const active = revision.steps.find((step) => step.status === 'active') + if (!active) throw new Error(`WorkflowRun ${run.id} 没有 active 步骤`) + return active +} + +function toActionSnapshot(run: WorkflowRun): WorkflowControllerSnapshot { + if (run.status !== 'active') return { phase: 'terminal', run } + const active = getActiveStep(run) + if (active.type === 'review') return { phase: 'action-review', run } + throw new Error(`动画恢复后未进入审核:${active.type}`) +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce8792..ded5344c 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,12 @@ -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 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 + * WorkflowController Feature 公开入口。 * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 + * pages 只从这里获取创作流程命令,不直接依赖 Controller 内部文件。 */ -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 { + CreateWorkflowControllerOptions, + WorkflowController, + WorkflowControllerSnapshot, +} from './controller' From 030f668737e3fc6a47c42a32c9e8828e56bf582c Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:46:16 +0800 Subject: [PATCH 08/27] feat(workflow-controller): return action review frames --- .../workflow-controller/controller.test.ts | 47 +++++++++++++++++-- .../workflow-controller/controller.ts | 37 +++++++++++---- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 9f6233bc..0b074b96 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import type { ActionFirstFrameCandidateBatch, + ActionReviewResult, CharacterCandidateBatch, WorkflowRun, WorkflowRunService, @@ -85,6 +86,7 @@ function createFixture(initialRuns: WorkflowRun[] = []) { resumeActionFirstFrameCandidates: vi.fn(), confirmActionFirstFrame: vi.fn(), resumeAction: vi.fn(), + getActionReview: vi.fn(), approveAction: vi.fn(), } as unknown as WorkflowRunService return { controller: createWorkflowController({ store, service }), store, service } @@ -93,6 +95,14 @@ function createFixture(initialRuns: WorkflowRun[] = []) { describe('createWorkflowController', () => { it('delegates business commands to WorkflowRun Service without saving snapshots itself', async () => { const { controller, store, service } = createFixture() + const reviewing = createRun('add_action', 'review') + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], + } + vi.mocked(service.confirmActionFirstFrame).mockResolvedValue(reviewing) + vi.mocked(service.getActionReview).mockResolvedValue(review) const characterInput = { projectId: 'project-1', prompt: '角色', driver: 'ai' as const } const actionInput = { projectId: 'project-1', @@ -107,10 +117,12 @@ describe('createWorkflowController', () => { await controller.startCharacter(characterInput) await controller.confirmCharacter({ runId: 'character-run', selectedImageUrl: 'character.png' }) await controller.startAction(actionInput) - await controller.confirmActionFirstFrame({ - runId: 'action-run', - selectedImageUrl: 'first-frame.png', - }) + await expect( + controller.confirmActionFirstFrame({ + runId: 'action-run', + selectedImageUrl: 'first-frame.png', + }), + ).resolves.toEqual(review) await controller.approveAction('action-run') expect(service.startCharacter).toHaveBeenCalledWith(characterInput) @@ -123,6 +135,7 @@ describe('createWorkflowController', () => { runId: 'action-run', selectedImageUrl: 'first-frame.png', }) + expect(service.getActionReview).toHaveBeenCalledWith(reviewing.id) expect(service.approveAction).toHaveBeenCalledWith('action-run') expect(store.save).not.toHaveBeenCalled() }) @@ -164,11 +177,35 @@ describe('createWorkflowController', () => { const reviewing = createRun('add_action', 'review') const { controller, service } = createFixture([generating]) vi.mocked(service.resumeAction).mockResolvedValue(reviewing) + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], + } + vi.mocked(service.getActionReview).mockResolvedValue(review) await expect(controller.resume(generating.id)).resolves.toEqual({ phase: 'action-review', - run: reviewing, + ...review, + }) + expect(service.getActionReview).toHaveBeenCalledWith(reviewing.id) + }) + + it('restores an existing review with frames instead of returning only the run', async () => { + const reviewing = createRun('add_action', 'review') + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }], + } + const { controller, service } = createFixture([reviewing]) + vi.mocked(service.getActionReview).mockResolvedValue(review) + + await expect(controller.resume(reviewing.id)).resolves.toEqual({ + phase: 'action-review', + ...review, }) + expect(service.resumeAction).not.toHaveBeenCalled() }) it('returns setup and terminal snapshots without invoking generation recovery', async () => { diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 5911e6d4..90070adb 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -8,6 +8,7 @@ import type { ActionFirstFrameCandidateBatch, + ActionReviewResult, CharacterCandidateBatch, ConfirmActionFirstFrameInput, ConfirmCharacterSelectionInput, @@ -23,7 +24,7 @@ import type { /** * 页面恢复结果。 * - * 候选阶段携带当次从后端取回的临时 URL;其他阶段只携带 Run。 + * 候选阶段携带当次从后端取回的临时 URL;动作审核阶段携带完整动画帧。 * 页面用 phase 选择界面,无需自己解释步骤顺序或后端任务状态。 */ export type WorkflowControllerSnapshot = @@ -31,7 +32,7 @@ export type WorkflowControllerSnapshot = | ({ phase: 'character-candidates' } & CharacterCandidateBatch) | { phase: 'action-setup'; run: WorkflowRun } | ({ phase: 'action-first-frame-candidates' } & ActionFirstFrameCandidateBatch) - | { phase: 'action-review'; run: WorkflowRun } + | ({ phase: 'action-review' } & ActionReviewResult) | { phase: 'terminal'; run: WorkflowRun } export interface WorkflowController { @@ -41,8 +42,8 @@ export interface WorkflowController { confirmCharacter(input: ConfirmCharacterSelectionInput): Promise /** 用户点击生成动作时创建独立 Run,返回 4 张动作首帧候选。 */ startAction(input: StartActionRunInput): Promise - /** 选中 1 张首帧后生成完整动画,直到进入审核。 */ - confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise + /** 选中 1 张首帧后生成完整动画,并返回审核页可直接播放的有序帧。 */ + confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise /** 审核通过后写入 Character,返回导入 Playtest 所需的稳定 ID。 */ approveAction(runId: WorkflowRun['id']): Promise @@ -111,17 +112,29 @@ export function createWorkflowController({ return { phase: 'action-first-frame-candidates', ...batch } } if (activeStep.type === 'complete-animation') { - return toActionSnapshot(await service.resumeAction(run.id)) + return toActionSnapshot(await service.resumeAction(run.id), service) + } + if (activeStep.type === 'review') { + const review = await service.getActionReview(run.id) + return { phase: 'action-review', ...review } } - if (activeStep.type === 'review') return { phase: 'action-review', run } throw new Error(`动作 WorkflowRun 无法恢复未知步骤:${activeStep.type}`) } + async function confirmActionFirstFrame( + input: ConfirmActionFirstFrameInput, + ): Promise { + // Service 先完成状态推进,再通过只读用例返回同一任务的审核帧。 + // Controller 不查询 Generation,也不把帧 URL 塞进 WorkflowRun Store。 + const run = await service.confirmActionFirstFrame(input) + return service.getActionReview(run.id) + } + return { startCharacter: (input) => service.startCharacter(input), confirmCharacter: (input) => service.confirmCharacter(input), startAction: (input) => service.startAction(input), - confirmActionFirstFrame: (input) => service.confirmActionFirstFrame(input), + confirmActionFirstFrame, approveAction: (runId) => service.approveAction(runId), getWorkflow, listWorkflows, @@ -139,9 +152,15 @@ function getActiveStep(run: WorkflowRun): WorkflowStep { return active } -function toActionSnapshot(run: WorkflowRun): WorkflowControllerSnapshot { +async function toActionSnapshot( + run: WorkflowRun, + service: WorkflowRunService, +): Promise { if (run.status !== 'active') return { phase: 'terminal', run } const active = getActiveStep(run) - if (active.type === 'review') return { phase: 'action-review', run } + if (active.type === 'review') { + const review = await service.getActionReview(run.id) + return { phase: 'action-review', ...review } + } throw new Error(`动画恢复后未进入审核:${active.type}`) } From 5ec5ba3aa71a3986163f6f24526b080aef144094 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:19:43 +0800 Subject: [PATCH 09/27] fix(workflow-controller): make task recovery idempotent --- .../workflow-controller/controller.test.ts | 72 ++++++++++++++++++- .../workflow-controller/controller.ts | 63 ++++++++++++++-- 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 0b074b96..0c38c4bf 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -35,7 +35,10 @@ function createRun( { id: 'step-1', type: activeType, - status: status === 'active' ? ('active' as const) : ('passed' as const), + status: + status === 'active' || status === 'interrupted' + ? ('active' as const) + : ('passed' as const), taskId: null, candidateTaskIds: [], submissionId: null, @@ -88,10 +91,20 @@ function createFixture(initialRuns: WorkflowRun[] = []) { resumeAction: vi.fn(), getActionReview: vi.fn(), approveAction: vi.fn(), + interruptRun: vi.fn(), + continueRun: vi.fn(), } as unknown as WorkflowRunService return { controller: createWorkflowController({ store, service }), store, service } } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + describe('createWorkflowController', () => { it('delegates business commands to WorkflowRun Service without saving snapshots itself', async () => { const { controller, store, service } = createFixture() @@ -103,6 +116,7 @@ describe('createWorkflowController', () => { } vi.mocked(service.confirmActionFirstFrame).mockResolvedValue(reviewing) vi.mocked(service.getActionReview).mockResolvedValue(review) + vi.mocked(service.interruptRun).mockReturnValue(reviewing) const characterInput = { projectId: 'project-1', prompt: '角色', driver: 'ai' as const } const actionInput = { projectId: 'project-1', @@ -124,6 +138,7 @@ describe('createWorkflowController', () => { }), ).resolves.toEqual(review) await controller.approveAction('action-run') + expect(controller.interrupt(reviewing.id)).toEqual(reviewing) expect(service.startCharacter).toHaveBeenCalledWith(characterInput) expect(service.confirmCharacter).toHaveBeenCalledWith({ @@ -137,6 +152,7 @@ describe('createWorkflowController', () => { }) expect(service.getActionReview).toHaveBeenCalledWith(reviewing.id) expect(service.approveAction).toHaveBeenCalledWith('action-run') + expect(service.interruptRun).toHaveBeenCalledWith(reviewing.id) expect(store.save).not.toHaveBeenCalled() }) @@ -225,6 +241,60 @@ describe('createWorkflowController', () => { expect(service.resumeAction).not.toHaveBeenCalled() }) + it('continues an interrupted run before restoring its active page', async () => { + const interrupted = createRun('create_character', 'character-setup', 'interrupted') + const active = { ...interrupted, status: 'active' as const } + const { controller, service } = createFixture([interrupted]) + vi.mocked(service.continueRun).mockReturnValue(active) + + await expect(controller.resume(interrupted.id)).resolves.toEqual({ + phase: 'character-setup', + run: active, + }) + expect(service.continueRun).toHaveBeenCalledWith(interrupted.id) + }) + + it('shares one in-flight recovery when the same run is resumed concurrently', async () => { + const run = createRun('add_action', 'first-frame-candidate') + const batch: ActionFirstFrameCandidateBatch = { + run, + candidateTaskIds: ['first-1', 'first-2', 'first-3', 'first-4'], + candidates: ['a.png', 'b.png', 'c.png', 'd.png'], + } + const pending = deferred() + const { controller, service } = createFixture([run]) + vi.mocked(service.resumeActionFirstFrameCandidates).mockReturnValue(pending.promise) + + const first = controller.resume(run.id) + const second = controller.resume(run.id) + expect(service.resumeActionFirstFrameCandidates).toHaveBeenCalledTimes(1) + + pending.resolve(batch) + await expect(Promise.all([first, second])).resolves.toEqual([ + { phase: 'action-first-frame-candidates', ...batch }, + { phase: 'action-first-frame-candidates', ...batch }, + ]) + }) + + it('reads the existing review when confirming a first frame is retried after advancement', async () => { + const reviewing = createRun('add_action', 'review') + const review: ActionReviewResult = { + run: reviewing as Extract, + generationId: 'animation-1', + frames: [{ imageUrl: 'frame-1.png' }], + } + const { controller, service } = createFixture([reviewing]) + vi.mocked(service.getActionReview).mockResolvedValue(review) + + await expect( + controller.confirmActionFirstFrame({ + runId: reviewing.id, + selectedImageUrl: 'first-frame.png', + }), + ).resolves.toEqual(review) + expect(service.confirmActionFirstFrame).not.toHaveBeenCalled() + }) + it('delegates reads, project filtering and subscriptions to Store', () => { const first = createRun('create_character', 'character-setup') const second = { ...createRun('add_action', 'action-setup'), projectId: 'project-2' } diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 90070adb..366702a0 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -46,15 +46,17 @@ export interface WorkflowController { confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise /** 审核通过后写入 Character,返回导入 Playtest 所需的稳定 ID。 */ approveAction(runId: WorkflowRun['id']): Promise + /** 暂停进行中的 Run;状态变更由 WorkflowRun Service 执行。 */ + interrupt(runId: WorkflowRun['id']): WorkflowRun /** 按 ID 读取防御性快照;不存在时返回 null。 */ getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null /** 列出全部 Run,可选按项目过滤,供后续历史页使用。 */ - listWorkflows(projectId?: string): WorkflowRun[] + listWorkflows(projectId?: string): readonly WorkflowRun[] /** 订阅单个 Run;Controller 不额外缓存副本。 */ subscribe(runId: WorkflowRun['id'], listener: (run: WorkflowRun) => void): () => void /** 订阅列表变化,供后续项目/历史视图复用。 */ - subscribeAll(listener: (runs: WorkflowRun[]) => void): () => void + subscribeAll(listener: (runs: readonly WorkflowRun[]) => void): () => void /** * 页面刷新或路由重进时的唯一恢复入口。 @@ -74,11 +76,17 @@ export function createWorkflowController({ store, service, }: CreateWorkflowControllerOptions): WorkflowController { + /** + * React StrictMode、路由重进或多个只读视图可能同时恢复同一个 Run。 + * 共享在途 Promise,避免 Service 为同一首帧阶段重复补建 Generation。 + */ + const pendingResumes = new Map>() + function getWorkflow(runId: WorkflowRun['id']): WorkflowRun | null { return store.get(runId) } - function listWorkflows(projectId?: string): WorkflowRun[] { + function listWorkflows(projectId?: string): readonly WorkflowRun[] { const runs = store.list() return projectId === undefined ? runs : runs.filter((run) => run.projectId === projectId) } @@ -87,13 +95,29 @@ export function createWorkflowController({ return store.subscribe(runId, listener) } - function subscribeAll(listener: (runs: WorkflowRun[]) => void) { + function subscribeAll(listener: (runs: readonly WorkflowRun[]) => void) { return store.subscribeAll(listener) } - async function resume(runId: WorkflowRun['id']): Promise { - const run = store.get(runId) + function resume(runId: WorkflowRun['id']): Promise { + const pending = pendingResumes.get(runId) + if (pending) return pending + + const request = restoreWorkflow(runId) + pendingResumes.set(runId, request) + const clear = () => { + if (pendingResumes.get(runId) === request) pendingResumes.delete(runId) + } + void request.then(clear, clear) + return request + } + + async function restoreWorkflow( + runId: WorkflowRun['id'], + ): Promise { + let run = store.get(runId) if (!run) return null + if (run.status === 'interrupted') run = service.continueRun(run.id) if (run.status !== 'active') return { phase: 'terminal', run } const activeStep = getActiveStep(run) @@ -124,6 +148,14 @@ export function createWorkflowController({ async function confirmActionFirstFrame( input: ConfirmActionFirstFrameInput, ): Promise { + const existing = store.get(input.runId) + if (existing?.purpose === 'add_action' && existing.status === 'active') { + const activeStep = getActiveStep(existing) + if (activeStep.type === 'complete-animation' || activeStep.type === 'review') { + return readActionReview(existing, service) + } + } + // Service 先完成状态推进,再通过只读用例返回同一任务的审核帧。 // Controller 不查询 Generation,也不把帧 URL 塞进 WorkflowRun Store。 const run = await service.confirmActionFirstFrame(input) @@ -136,6 +168,7 @@ export function createWorkflowController({ startAction: (input) => service.startAction(input), confirmActionFirstFrame, approveAction: (runId) => service.approveAction(runId), + interrupt: (runId) => service.interruptRun(runId), getWorkflow, listWorkflows, subscribe, @@ -144,6 +177,24 @@ export function createWorkflowController({ } } +/** + * 首帧确认已经把 Run 推进到动画或审核阶段时,重试只能读取既有结果。 + * 再次调用确认用例会重复消费旧候选,并把已经成功推进的任务误报为失败。 + */ +async function readActionReview( + run: Extract, + service: WorkflowRunService, +): Promise { + let reviewing: WorkflowRun = run + if (getActiveStep(reviewing).type === 'complete-animation') { + reviewing = await service.resumeAction(reviewing.id) + } + if (reviewing.status !== 'active' || getActiveStep(reviewing).type !== 'review') { + throw new Error('动作首帧已确认,但任务尚未进入审核阶段') + } + return service.getActionReview(reviewing.id) +} + function getActiveStep(run: WorkflowRun): WorkflowStep { const revision = run.revisions.find((item) => item.id === run.currentRevisionId) if (!revision) throw new Error(`WorkflowRun ${run.id} 的 currentRevisionId 无效`) From 600120f673fe225ef2ec9980f1d1f56eebafd70c Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:52:42 +0800 Subject: [PATCH 10/27] fix(workflow-run): close async state races --- .../service/workflow-run-service.test.ts | 87 ++++++++++++++++++- .../service/workflow-run-service.ts | 51 ++++++++--- .../store/workflow-run-store.test.ts | 25 ++++++ .../workflow-run/store/workflow-run-store.ts | 4 + 4 files changed, 154 insertions(+), 13 deletions(-) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index af18dc50..c4e2c9f6 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Character, CharacterApis } from '../../character' -import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' import { createWorkflowRunStore } from '../store' import { createWorkflowRunService, @@ -263,6 +263,91 @@ describe('createWorkflowRunService', () => { expect(store.get(batch.run.id)?.status).toBe('active') }) + it('rechecks the task after subscribing so a terminal event cannot be missed', async () => { + const fixture = createService() + const running: Generation<'character_template'> = { + id: 'generation-race', + projectId: 'project-1', + type: 'character_template', + status: 'running', + result: null, + error: null, + } + const completed: Generation<'character_template'> = { + ...running, + status: 'completed', + result: { + type: 'character_template', + images: [1, 2, 3, 4].map((index) => ({ url: `race-candidate-${index}.png` })), + }, + } + fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] + fixture.generation.apis.get = vi.fn(async () => completed) + fixture.generation.apis.subscribe = vi.fn(() => () => undefined) + + const batch = await fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '竞态测试角色', + driver: 'ai', + }) + + expect(batch.candidates).toEqual([ + 'race-candidate-1.png', + 'race-candidate-2.png', + 'race-candidate-3.png', + 'race-candidate-4.png', + ]) + expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1) + expect(fixture.generation.apis.get).toHaveBeenCalledWith('project-1', 'generation-race') + }) + + it('does not advance an interrupted run when an in-flight generation finishes', async () => { + const fixture = createService() + const running: Generation<'character_template'> = { + id: 'generation-interrupted', + projectId: 'project-1', + type: 'character_template', + status: 'running', + result: null, + error: null, + } + let emit: (event: GenerationEvent) => void = () => { + throw new Error('生成订阅尚未建立') + } + fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] + fixture.generation.apis.get = vi.fn(async () => running) + fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { + emit = onEvent + return () => undefined + }) + + const pending = fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '可中断角色', + driver: 'ai', + }) + await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) + const runId = fixture.store.list()[0]!.id + fixture.service.interruptRun(runId) + emit({ + taskId: running.id, + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [1, 2, 3, 4].map((index) => ({ url: `late-candidate-${index}.png` })), + }, + error: null, + }) + + await expect(pending).rejects.toThrow('WorkflowRun 已中断,不能推进生成步骤') + const interrupted = fixture.store.get(runId)! + expect(interrupted.status).toBe('interrupted') + expect( + interrupted.revisions[0]?.steps.find((step) => step.type === 'character-template')?.status, + ).toBe('active') + }) + it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { const { service, store, generation } = createService() const run = store.create({ diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 09c4e426..53e08bee 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -189,7 +189,8 @@ export function createWorkflowRunService({ prompt, referenceMedia: input.referenceMedia ?? [], }) - run = recordTask(run, 'character-template', generation.id, now()) + // create() 等待期间用户可能已经中断 Run;写 taskId 前重新读取最新快照。 + run = recordTask(requireRun(store, run.id), 'character-template', generation.id, now()) store.save(run) const terminal = await waitForTerminal(generationApis, generation) const result = requireCharacterCandidates(terminal) @@ -218,7 +219,8 @@ export function createWorkflowRunService({ await generationApis.get(run.projectId, templateStep.taskId), ) const result = requireCharacterCandidates(terminal) - if (templateStep.status === 'active') { + run = requireRun(store, run.id) + if (requireStep(run, 'character-template').status === 'active') { run = completeGenerationStep(run, 'character-template', 'template-candidate', now()) store.save(run) } @@ -357,7 +359,8 @@ export function createWorkflowRunService({ prompt: run.prompt, referenceMedia: [characterImageUrl as MediaReference], }) - run = appendCandidateTask(run, 'first-frame', task.id, now()) + // 每个候选请求都可能跨过一次用户中断,不能用请求前的旧快照继续推进。 + run = appendCandidateTask(requireRun(store, run.id), 'first-frame', task.id, now()) store.save(run) firstFrameStep = requireStep(run, 'first-frame') } @@ -375,6 +378,8 @@ export function createWorkflowRunService({ ), ) const candidates = terminals.map(requireFirstFrame) + run = requireRun(store, run.id) + firstFrameStep = requireStep(run, 'first-frame') if (firstFrameStep.status === 'active') { run = completeGenerationStep(run, 'first-frame', 'first-frame-candidate', now()) store.save(run) @@ -403,7 +408,11 @@ export function createWorkflowRunService({ prompt: run.prompt, referenceMedia: [], }) - const generating = startAnimationFromCandidate(run, animationTask.id, now()) + const generating = startAnimationFromCandidate( + requireRun(store, run.id), + animationTask.id, + now(), + ) store.save(generating) const terminal = await waitForTerminal(generationApis, animationTask) requireAnimation(terminal) @@ -435,7 +444,7 @@ export function createWorkflowRunService({ await generationApis.get(run.projectId, animationStep.taskId), ) requireAnimation(terminal) - run = completeGenerationStep(run, 'complete-animation', 'review', now()) + run = completeGenerationStep(requireRun(store, run.id), 'complete-animation', 'review', now()) store.save(run) return run } catch (cause) { @@ -591,6 +600,7 @@ function recordTask( taskId: string, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能记录生成任务') return editCurrentRevision(run, updatedAt, (revision) => { const step = requireRevisionStep(revision, type) if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) @@ -611,6 +621,7 @@ function appendCandidateTask( taskId: string, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能追加首帧候选') return editCurrentRevision(run, updatedAt, (revision) => { const step = requireRevisionStep(revision, type) if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) @@ -630,6 +641,7 @@ function startAnimationFromCandidate( taskId: string, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能开始完整动画') return editCurrentRevision(run, updatedAt, (revision) => { const candidate = requireRevisionStep(revision, 'first-frame-candidate') const animation = requireRevisionStep(revision, 'complete-animation') @@ -650,6 +662,7 @@ function completeGenerationStep( nextType: WorkflowStepType, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能推进生成步骤') return editCurrentRevision(run, updatedAt, (revision) => { const current = requireRevisionStep(revision, currentType) const next = requireRevisionStep(revision, nextType) @@ -746,12 +759,21 @@ function waitForTerminal( } return new Promise((resolve, reject) => { let stop: () => void = () => undefined - let settledBeforeSubscription = false - const settle = (event: GenerationEvent) => { - if (event.status !== 'completed' && event.status !== 'failed') return - settledBeforeSubscription = true + let settled = false + const fail = (cause: unknown) => { + if (settled) return + settled = true stop() - resolve({ + reject(asError(cause)) + } + const settleGeneration = (snapshot: Generation) => { + if (settled || (snapshot.status !== 'completed' && snapshot.status !== 'failed')) return + settled = true + stop() + resolve(snapshot) + } + const settle = (event: GenerationEvent) => { + settleGeneration({ id: event.taskId, projectId: generation.projectId, type: event.type, @@ -762,9 +784,14 @@ function waitForTerminal( } try { stop = generationApis.subscribe(generation.projectId, generation.id, settle) - if (settledBeforeSubscription) stop() + if (settled) { + stop() + return + } + // 先订阅再复查快照,封住“首次 get 尚未完成、subscribe 前已完成”的竞态窗口。 + void generationApis.get(generation.projectId, generation.id).then(settleGeneration, fail) } catch (cause) { - reject(asError(cause)) + fail(cause) } }) } diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index d1c53f5f..acb4f2aa 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -337,6 +337,31 @@ describe('createWorkflowRunStore', () => { expect(() => store.save(missingSelection)).toThrow('Invalid WorkflowRun snapshot') }) + it('rejects completed snapshots that still contain a failed step', () => { + const invalid = createRun() + invalid.status = 'completed' + invalid.characterId = 'character-1' + invalid.outfitId = 'outfit-1' + invalid.selectedAt = '2026-08-03T03:00:00.000Z' + invalid.revisions[0]!.status = 'completed' + invalid.revisions[0]!.steps = invalid.revisions[0]!.steps.map((step) => ({ + ...step, + status: 'passed', + })) + invalid.revisions[0]!.steps[0]!.status = 'failed' + invalid.revisions[0]!.steps[0]!.error = '生成失败却被标记为完成' + + const store = createWorkflowRunStore({ storage: null }) + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + + const hydrated = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), + ), + }) + expect(hydrated.get(invalid.id)).toBeNull() + }) + // taskId 是追踪线索,不是仅在加载中存活的 UI 状态。 it('retains a generation task id after its step passes', () => { const run = createRun() diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index 438be22c..9d38b566 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -248,6 +248,10 @@ function isWorkflowRun(value: unknown): value is WorkflowRun { ) { return false } + // completed 表示本次任务的每一步都已经成功,不能夹带 failed/locked 等残留状态。 + if (value.status === 'completed' && current.steps.some((step) => step.status !== 'passed')) { + return false + } // // 角色 Run 有两个合法阶段:尚未选择时三个字段都为 null, From 2f7c04df9566015c9cbbd681cf611a5b812c6d28 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:52:42 +0800 Subject: [PATCH 11/27] fix(workflow-run): close async state races --- .../service/workflow-run-service.test.ts | 87 ++++++++++++++++++- .../service/workflow-run-service.ts | 51 ++++++++--- .../store/workflow-run-store.test.ts | 25 ++++++ .../workflow-run/store/workflow-run-store.ts | 4 + 4 files changed, 154 insertions(+), 13 deletions(-) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index af18dc50..c4e2c9f6 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Character, CharacterApis } from '../../character' -import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' import { createWorkflowRunStore } from '../store' import { createWorkflowRunService, @@ -263,6 +263,91 @@ describe('createWorkflowRunService', () => { expect(store.get(batch.run.id)?.status).toBe('active') }) + it('rechecks the task after subscribing so a terminal event cannot be missed', async () => { + const fixture = createService() + const running: Generation<'character_template'> = { + id: 'generation-race', + projectId: 'project-1', + type: 'character_template', + status: 'running', + result: null, + error: null, + } + const completed: Generation<'character_template'> = { + ...running, + status: 'completed', + result: { + type: 'character_template', + images: [1, 2, 3, 4].map((index) => ({ url: `race-candidate-${index}.png` })), + }, + } + fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] + fixture.generation.apis.get = vi.fn(async () => completed) + fixture.generation.apis.subscribe = vi.fn(() => () => undefined) + + const batch = await fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '竞态测试角色', + driver: 'ai', + }) + + expect(batch.candidates).toEqual([ + 'race-candidate-1.png', + 'race-candidate-2.png', + 'race-candidate-3.png', + 'race-candidate-4.png', + ]) + expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1) + expect(fixture.generation.apis.get).toHaveBeenCalledWith('project-1', 'generation-race') + }) + + it('does not advance an interrupted run when an in-flight generation finishes', async () => { + const fixture = createService() + const running: Generation<'character_template'> = { + id: 'generation-interrupted', + projectId: 'project-1', + type: 'character_template', + status: 'running', + result: null, + error: null, + } + let emit: (event: GenerationEvent) => void = () => { + throw new Error('生成订阅尚未建立') + } + fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] + fixture.generation.apis.get = vi.fn(async () => running) + fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { + emit = onEvent + return () => undefined + }) + + const pending = fixture.service.startCharacter({ + projectId: 'project-1', + prompt: '可中断角色', + driver: 'ai', + }) + await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) + const runId = fixture.store.list()[0]!.id + fixture.service.interruptRun(runId) + emit({ + taskId: running.id, + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [1, 2, 3, 4].map((index) => ({ url: `late-candidate-${index}.png` })), + }, + error: null, + }) + + await expect(pending).rejects.toThrow('WorkflowRun 已中断,不能推进生成步骤') + const interrupted = fixture.store.get(runId)! + expect(interrupted.status).toBe('interrupted') + expect( + interrupted.revisions[0]?.steps.find((step) => step.type === 'character-template')?.status, + ).toBe('active') + }) + it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { const { service, store, generation } = createService() const run = store.create({ diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 09c4e426..53e08bee 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -189,7 +189,8 @@ export function createWorkflowRunService({ prompt, referenceMedia: input.referenceMedia ?? [], }) - run = recordTask(run, 'character-template', generation.id, now()) + // create() 等待期间用户可能已经中断 Run;写 taskId 前重新读取最新快照。 + run = recordTask(requireRun(store, run.id), 'character-template', generation.id, now()) store.save(run) const terminal = await waitForTerminal(generationApis, generation) const result = requireCharacterCandidates(terminal) @@ -218,7 +219,8 @@ export function createWorkflowRunService({ await generationApis.get(run.projectId, templateStep.taskId), ) const result = requireCharacterCandidates(terminal) - if (templateStep.status === 'active') { + run = requireRun(store, run.id) + if (requireStep(run, 'character-template').status === 'active') { run = completeGenerationStep(run, 'character-template', 'template-candidate', now()) store.save(run) } @@ -357,7 +359,8 @@ export function createWorkflowRunService({ prompt: run.prompt, referenceMedia: [characterImageUrl as MediaReference], }) - run = appendCandidateTask(run, 'first-frame', task.id, now()) + // 每个候选请求都可能跨过一次用户中断,不能用请求前的旧快照继续推进。 + run = appendCandidateTask(requireRun(store, run.id), 'first-frame', task.id, now()) store.save(run) firstFrameStep = requireStep(run, 'first-frame') } @@ -375,6 +378,8 @@ export function createWorkflowRunService({ ), ) const candidates = terminals.map(requireFirstFrame) + run = requireRun(store, run.id) + firstFrameStep = requireStep(run, 'first-frame') if (firstFrameStep.status === 'active') { run = completeGenerationStep(run, 'first-frame', 'first-frame-candidate', now()) store.save(run) @@ -403,7 +408,11 @@ export function createWorkflowRunService({ prompt: run.prompt, referenceMedia: [], }) - const generating = startAnimationFromCandidate(run, animationTask.id, now()) + const generating = startAnimationFromCandidate( + requireRun(store, run.id), + animationTask.id, + now(), + ) store.save(generating) const terminal = await waitForTerminal(generationApis, animationTask) requireAnimation(terminal) @@ -435,7 +444,7 @@ export function createWorkflowRunService({ await generationApis.get(run.projectId, animationStep.taskId), ) requireAnimation(terminal) - run = completeGenerationStep(run, 'complete-animation', 'review', now()) + run = completeGenerationStep(requireRun(store, run.id), 'complete-animation', 'review', now()) store.save(run) return run } catch (cause) { @@ -591,6 +600,7 @@ function recordTask( taskId: string, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能记录生成任务') return editCurrentRevision(run, updatedAt, (revision) => { const step = requireRevisionStep(revision, type) if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) @@ -611,6 +621,7 @@ function appendCandidateTask( taskId: string, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能追加首帧候选') return editCurrentRevision(run, updatedAt, (revision) => { const step = requireRevisionStep(revision, type) if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) @@ -630,6 +641,7 @@ function startAnimationFromCandidate( taskId: string, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能开始完整动画') return editCurrentRevision(run, updatedAt, (revision) => { const candidate = requireRevisionStep(revision, 'first-frame-candidate') const animation = requireRevisionStep(revision, 'complete-animation') @@ -650,6 +662,7 @@ function completeGenerationStep( nextType: WorkflowStepType, updatedAt: string, ): WorkflowRun { + if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能推进生成步骤') return editCurrentRevision(run, updatedAt, (revision) => { const current = requireRevisionStep(revision, currentType) const next = requireRevisionStep(revision, nextType) @@ -746,12 +759,21 @@ function waitForTerminal( } return new Promise((resolve, reject) => { let stop: () => void = () => undefined - let settledBeforeSubscription = false - const settle = (event: GenerationEvent) => { - if (event.status !== 'completed' && event.status !== 'failed') return - settledBeforeSubscription = true + let settled = false + const fail = (cause: unknown) => { + if (settled) return + settled = true stop() - resolve({ + reject(asError(cause)) + } + const settleGeneration = (snapshot: Generation) => { + if (settled || (snapshot.status !== 'completed' && snapshot.status !== 'failed')) return + settled = true + stop() + resolve(snapshot) + } + const settle = (event: GenerationEvent) => { + settleGeneration({ id: event.taskId, projectId: generation.projectId, type: event.type, @@ -762,9 +784,14 @@ function waitForTerminal( } try { stop = generationApis.subscribe(generation.projectId, generation.id, settle) - if (settledBeforeSubscription) stop() + if (settled) { + stop() + return + } + // 先订阅再复查快照,封住“首次 get 尚未完成、subscribe 前已完成”的竞态窗口。 + void generationApis.get(generation.projectId, generation.id).then(settleGeneration, fail) } catch (cause) { - reject(asError(cause)) + fail(cause) } }) } diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index d1c53f5f..acb4f2aa 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -337,6 +337,31 @@ describe('createWorkflowRunStore', () => { expect(() => store.save(missingSelection)).toThrow('Invalid WorkflowRun snapshot') }) + it('rejects completed snapshots that still contain a failed step', () => { + const invalid = createRun() + invalid.status = 'completed' + invalid.characterId = 'character-1' + invalid.outfitId = 'outfit-1' + invalid.selectedAt = '2026-08-03T03:00:00.000Z' + invalid.revisions[0]!.status = 'completed' + invalid.revisions[0]!.steps = invalid.revisions[0]!.steps.map((step) => ({ + ...step, + status: 'passed', + })) + invalid.revisions[0]!.steps[0]!.status = 'failed' + invalid.revisions[0]!.steps[0]!.error = '生成失败却被标记为完成' + + const store = createWorkflowRunStore({ storage: null }) + expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') + + const hydrated = createWorkflowRunStore({ + storage: new TestStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), + ), + }) + expect(hydrated.get(invalid.id)).toBeNull() + }) + // taskId 是追踪线索,不是仅在加载中存活的 UI 状态。 it('retains a generation task id after its step passes', () => { const run = createRun() diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index 438be22c..9d38b566 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -248,6 +248,10 @@ function isWorkflowRun(value: unknown): value is WorkflowRun { ) { return false } + // completed 表示本次任务的每一步都已经成功,不能夹带 failed/locked 等残留状态。 + if (value.status === 'completed' && current.steps.some((step) => step.status !== 'passed')) { + return false + } // // 角色 Run 有两个合法阶段:尚未选择时三个字段都为 null, From f1f7f1093d1a4e9bcece06750ffc9fdec730868a Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:10:11 +0800 Subject: [PATCH 12/27] refactor(workflow-run): align run model with editor cards --- README.md | 36 +- frontend/README.md | 34 +- frontend/src/entities/generation/index.ts | 4 +- frontend/src/entities/index.ts | 35 +- frontend/src/entities/workflow-run/README.md | 36 + frontend/src/entities/workflow-run/index.ts | 38 +- .../entities/workflow-run/model/constants.ts | 77 +- .../src/entities/workflow-run/model/index.ts | 24 +- .../src/entities/workflow-run/model/types.ts | 233 ++-- .../entities/workflow-run/service/index.ts | 6 +- .../service/workflow-run-service.test.ts | 430 ++----- .../service/workflow-run-service.ts | 1094 ++++++++--------- .../src/entities/workflow-run/store/index.ts | 16 +- .../store/workflow-run-store.test.ts | 514 ++------ .../workflow-run/store/workflow-run-store.ts | 572 ++++----- .../src/features/workflow-controller/index.ts | 55 +- 16 files changed, 1135 insertions(+), 2069 deletions(-) create mode 100644 frontend/src/entities/workflow-run/README.md diff --git a/README.md b/README.md index 58c325b6..6de50c63 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,34 @@ -# game-asset-character -Generate high-quality 2D game characters. +# WorkflowRun Core + +该目录是 PR #86 的独立工作区,只提交 WorkflowRun 前端核心及必要测试。 + +## 当前定义 + +- 一个根任务对应一个 `WorkflowRun`。 +- Quick Start 的“生成角色、选择角色、生成首个动作、审核导入”属于同一个 Run。 +- 给已有角色追加动作是新的根任务,因此创建新的 Run。 +- Run 直接保存 `steps`,不内嵌 `Revision` 版本树。 +- 一个 Step 与 Workflow Editor 的一张卡片一一对应,生成、选择、审核等内部过程使用 `phase`。 +- Generation 负责单个异步生成任务和 SSE;WorkflowRun 只引用任务 ID。 +- Repository 的 `create/get/list/save` 全部异步,当前 localStorage 只是临时适配器。 +- Repository 不提供 `subscribe/subscribeAll`;运行实例提供显式 `save()`。 + +## Workflow Editor 边界 + +Workflow Editor 将维护独立的 `WorkflowDefinition`。编辑节点得到新的定义版本;执行某个 +定义版本时创建新的 WorkflowRun。当前不提前定义编辑器版本结构,也不会在一个 Run 中 +嵌套 `revisions[]`。这样定义版本、执行历史和生成任务分别归属各自模块。 + +## 验证 + +```bash +cd frontend +npm ci +npm run format:check +npm run lint +npm run typecheck +npm run test +npm run build +``` + +不要提交 `node_modules/`、`dist/`、测试覆盖率或本地数据库。 diff --git a/frontend/README.md b/frontend/README.md index 6e01fa34..dc063739 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,30 +1,12 @@ -# Windup 前端 +# Windup Frontend - WorkflowRun Split -React + Vite + TypeScript。 +该前端快照用于单独验证 WorkflowRun PR,不代表其他前端模块已经随本 PR 实现。 -## 开发 +主要代码位于 `src/entities/workflow-run/`: -```bash -npm ci -npm run dev -``` +- `model/`:一次根任务的可序列化 Run 和 Step。 +- `store/`:异步 Repository 合同与 localStorage 适配器。 +- `service/`:创建/读取绑定 Run 的实例,并组合 Character、Generation API。 -## 检查 - -```bash -npm run format:check # 格式 -npm run lint # 静态检查 -npm run typecheck # 类型 -npm run test # 测试(本阶段无测试文件) -npm run build # 构建 -``` - -CI 按上面顺序全跑一遍。 - -## 结构 - -模块划分、依赖规则与命名约定见仓库根目录 `frontend-architecture-v3.md`。 - -**本阶段只提交模块边界与接口,不含实现。** 页面是占位外壳,各模块只有类型与 `XxxApis` 接口。实现按模块拆成后续 PR。 - -与后端尚未对齐的接口见 `API_CONTRACT.md`。 +运行 `npm ci` 后,通过 `npm run test`、`npm run typecheck`、`npm run lint` 和 +`npm run build` 完成验证。详细边界见模块目录的 `README.md`。 diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index cadb63d4..33474787 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -10,8 +10,8 @@ import type { MediaReference } from '../media' */ /** - * 后端 GenerationTask.status,与 WorkflowRevision.generationStatus 不是一回事: - * 这里是单次生成任务的状态,那里是一个版本在生成阶段的汇总状态。 + * 后端 GenerationTask.status 是单次生成任务状态,不等于 WorkflowRun 或卡片的状态。 + * 一个 Run/Step 可以引用零个、一个或多个 GenerationTask。 * pending 表示已提交但尚未执行。 */ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index bf05b437..f67d5580 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -68,35 +68,34 @@ export type { MediaReference } from './media' export { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT, + createWorkflowRunRepository, createWorkflowRunService, - createWorkflowRunStore, + isWorkflowRunSnapshot, WORKFLOW_STEP_ORDERS, } from './workflow-run' export type { ActionFirstFrameCandidateBatch, - ActionReviewFrame, ActionReviewResult, - CreateWorkflowRunStoreOptions, + BuiltinWorkflowRunSource, + ConfigureWorkflowActionInput, + CreateWorkflowRunRepositoryOptions, CreateWorkflowRunServiceOptions, CreateWorkflowRunInput, CharacterCandidateBatch, CharacterCandidateConfirmationApis, - ConfirmCharacterSelectionInput, - ConfirmActionFirstFrameInput, - ExportStatus, - GenerationStatus, - WorkflowDriver, - WorkflowStep, - WorkflowStepStatus, - WorkflowStepType, - WorkflowRevision, - WorkflowRevisionStatus, + PublishActionResult, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, WorkflowRun, - WorkflowRunStore, + WorkflowRunKind, + WorkflowRunRepository, WorkflowRunService, - WorkflowRunPurpose, + WorkflowRunSnapshot, WorkflowRunStatus, - PublishActionResult, - StartActionRunInput, - StartCharacterRunInput, + WorkflowStep, + WorkflowStepPhase, + WorkflowStepStatus, + WorkflowStepType, } 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..d299b01e --- /dev/null +++ b/frontend/src/entities/workflow-run/README.md @@ -0,0 +1,36 @@ +# WorkflowRun + +WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模式,也不表示单个生成任务。 + +## 数据关系 + +```text +WorkflowDefinition(未来由 Workflow Editor 管理) + └─ WorkflowRun(一次执行) + └─ WorkflowStep(与编辑器卡片一一对应) + └─ GenerationTask 引用(可以有 0、1 或多个) +``` + +Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 +`ai/manual driver`。角色创建和首个动作在 Quick Start 中属于一个根任务;用户以后 +单独追加动作时才创建另一个 Run。 + +## Step 与卡片 + +当前内置流程只有 `character` 和 `action` 两种 Step。角色卡片内部依次经历“生成四张候选、 +选择一张”;动作卡片内部依次经历“配置、生成四张首帧、选择首帧、生成动画、审核、导出”。 +这些内部过程由 `phase` 表达,不拆成额外 Step,因此编辑器无需再写一层合并转换逻辑。 + +## 为什么没有 Revision + +当前产品只需要刷新恢复、失败重试和重新发起任务。失败重试更新当前 Run;整体重做创建 +新 Run。Workflow Editor 的节点编辑属于 WorkflowDefinition 版本,不属于执行快照。 +只有确认需要在同一执行中浏览、切换或回滚多条分支时,才重新评估 Revision。 + +## 职责 + +- `model` 不依赖页面、localStorage 或 SSE。 +- `store` 只负责异步持久化,不提供页面订阅。 +- `service` 只提供 `create/get`,返回绑定 Run ID 的实例;运行操作不再重复传 `runId`。 +- 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 +- Generation SSE 继续由 Generation Entity 负责。 diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 00d9292e..4e79891a 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,9 +1,4 @@ -/** - * WorkflowRun Entity 的对外入口。 - * - * 外部模块只从这里获取 WorkflowRun 能力,不绕过入口直接依赖 model/store - * 内部文件。这样既保留了子目录的职责分工,又不把内部结构变成全仓库 API。 - */ +/** WorkflowRun Entity 的唯一公开入口。 */ export { ACTION_FIRST_FRAME_CANDIDATE_COUNT, @@ -11,33 +6,36 @@ export { WORKFLOW_STEP_ORDERS, } from './model' export type { + BuiltinWorkflowRunSource, + ConfigureWorkflowActionInput, CreateWorkflowRunInput, - ExportStatus, - GenerationStatus, - WorkflowDriver, - WorkflowRevision, - WorkflowRevisionStatus, - WorkflowRun, - WorkflowRunPurpose, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowRunKind, + WorkflowRunSnapshot, WorkflowRunStatus, WorkflowStep, + WorkflowStepPhase, WorkflowStepStatus, WorkflowStepType, } from './model' -export { createWorkflowRunStore } from './store' -export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' +export { + createWorkflowRunRepository, + isWorkflowRunSnapshot, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, +} from './store' +export type { CreateWorkflowRunRepositoryOptions, WorkflowRunRepository } from './store' export { createWorkflowRunService } from './service' export type { ActionFirstFrameCandidateBatch, - ActionReviewFrame, ActionReviewResult, CharacterCandidateBatch, CharacterCandidateConfirmationApis, - ConfirmCharacterSelectionInput, - ConfirmActionFirstFrameInput, CreateWorkflowRunServiceOptions, PublishActionResult, - StartActionRunInput, - StartCharacterRunInput, + WorkflowRun, WorkflowRunService, } from './service' diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts index 375bda34..66549fb1 100644 --- a/frontend/src/entities/workflow-run/model/constants.ts +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -1,63 +1,46 @@ -/** - * WorkflowRun 的业务词汇和步骤模板。 - * - * 常量数组同时服务于三个地方:TypeScript 联合类型、运行时水合校验、 - * 以及页面的进度顺序。只保留一份定义,可以避免“类型说可以,恢复时却拒绝”。 - */ - -/** 该 Run 是由 AI 自动引导,还是用户在编辑器中手动推进。 */ -export const WORKFLOW_DRIVERS = ['ai', 'manual'] as const +/** WorkflowRun 使用的稳定业务词汇。 */ /** - * 一个 Run 只有一个目标。新建角色和追加动作可在同一界面连续操作, - * 但是两次独立任务,因此使用两个 WorkflowRun。 + * 一个 Run 对应从一个根节点开始的一次执行。 + * `character_action` 会在同一个 Run 中先完成角色卡片,再完成动作卡片; + * `add_action` 则从已有角色开始,只包含动作卡片。 */ -export const WORKFLOW_PURPOSES = ['create_character', 'add_action'] as const +export const WORKFLOW_RUN_KINDS = ['character_action', 'add_action'] as const -/** Run 级状态:描述整个用户任务,不等于某次后端生成任务的状态。 */ export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const -/** - * Revision 级状态。用户从旧步骤重做时,旧 Revision 变为 abandoned, - * 并追加新 Revision;不覆盖历史,才能说清“这个结果从哪次重做而来”。 - */ -export const WORKFLOW_REVISION_STATUSES = ['active', 'completed', 'failed', 'abandoned'] as const +/** Step 与 Workflow Editor 中用户看到的卡片一一对应。 */ +export const WORKFLOW_STEP_TYPES = ['character', 'action'] as const -/** 当前 Revision 中生成阶段的汇总状态,不是单个 GenerationTask.status。 */ -export const GENERATION_STATUSES = ['not_started', 'in_progress', 'completed', 'failed'] as const - -/** 导出阶段的汇总状态;角色生成 Run 没有导出步骤时保持 not_exported。 */ -export const EXPORT_STATUSES = ['not_exported', 'exporting', 'exported', 'failed'] as const +export const WORKFLOW_STEP_STATUSES = ['locked', 'active', 'passed', 'failed'] as const /** - * 单个步骤的状态。locked 表示前置条件未满足,available 表示可开始, - * active 表示当前正在处理,passed/failed 是已结束结果。 + * phase 描述卡片内部正在做什么,不再把“生成”和“选择”伪装成两张卡片。 + * 不同类型的 Step 只能使用各自对应的 phase,校验规则在 Repository 中集中维护。 */ -export const WORKFLOW_STEP_STATUSES = ['locked', 'available', 'active', 'passed', 'failed'] as const +export const WORKFLOW_STEP_PHASES = [ + 'generating_character_candidates', + 'selecting_character', + 'configuring_action', + 'generating_action_candidates', + 'selecting_action_frame', + 'generating_animation', + 'reviewing_animation', + 'exporting_action', + 'completed', +] as const + +export const WORKFLOW_GENERATION_ROLES = [ + 'character_candidates', + 'action_frame_candidate', + 'animation', +] as const -/** 角色形象每次生成 4 张临时候选;用户只会确认其中 1 张为正式资产。 */ export const CHARACTER_CANDIDATE_COUNT = 4 - -/** 动作也先生成 4 张独立首帧,避免错误姿势直接扩展成完整动画。 */ export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 -/** - * 按任务目的分开定义步骤顺序。 - * - * create_character 到“四选一并保存正式角色”就结束; - * add_action 从已有角色/造型开始,不重复跑角色母版生成。 - * - * 两个 Run 可以由同一页面连续展示,但数据上必须拆开,否则历史记录、 - * 失败重试和后续追加动作都无法准确归属。 - */ +/** 两个内置流程的卡片顺序;将来编辑器流程由 WorkflowDefinition 提供节点顺序。 */ export const WORKFLOW_STEP_ORDERS = { - create_character: ['character-setup', 'character-template', 'template-candidate'], - add_action: [ - 'action-setup', - 'first-frame', - 'first-frame-candidate', - 'complete-animation', - 'review', - 'export', - ], + character_action: ['character', 'action'], + add_action: ['action'], } as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts index fe954e68..7bfa4678 100644 --- a/frontend/src/entities/workflow-run/model/index.ts +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -1,10 +1,4 @@ -/** - * WorkflowRun 领域模型的子目录入口。 - * - * 本目录只定义“WorkflowRun 是什么”:业务词汇、步骤模板、Run/Revision/Step - * 类型以及创建输入。它不知道 localStorage、订阅者或页面,因此可被 - * Store、Controller 和页面共同依赖,而不产生反向依赖。 - */ +/** WorkflowRun 的可序列化模型和内置流程模板。 */ export { ACTION_FIRST_FRAME_CANDIDATE_COUNT, @@ -12,16 +6,18 @@ export { WORKFLOW_STEP_ORDERS, } from './constants' export type { + BuiltinWorkflowRunSource, + ConfigureWorkflowActionInput, CreateWorkflowRunInput, - ExportStatus, - GenerationStatus, - WorkflowDriver, - WorkflowRevision, - WorkflowRevisionStatus, - WorkflowRun, - WorkflowRunPurpose, + WorkflowActionInput, + WorkflowCharacterInput, + WorkflowGenerationRef, + WorkflowGenerationRole, + WorkflowRunKind, + WorkflowRunSnapshot, WorkflowRunStatus, WorkflowStep, + WorkflowStepPhase, WorkflowStepStatus, WorkflowStepType, } from './types' diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index efda4048..d0a7f02d 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -1,205 +1,120 @@ -/** - * WorkflowRun Entity 的公开业务模型。 - * - * 层级关系是 WorkflowRun(一次用户任务) -> WorkflowRevision(一条重做版本) - * -> WorkflowStep(版本中的一个步骤)。后端 Generation 只是某个步骤引用的异步任务, - * 不能代替 WorkflowRun;角色和动作是最终资产,也不应嵌进运行历史。 - */ +/** WorkflowRun 的可序列化执行快照。 */ -import type { Generation } from '../../generation' import type { ActionType } from '../../character' +import type { Generation } from '../../generation' +import type { MediaReference } from '../../media' import { - EXPORT_STATUSES, - GENERATION_STATUSES, - WORKFLOW_DRIVERS, - WORKFLOW_PURPOSES, - WORKFLOW_REVISION_STATUSES, + WORKFLOW_GENERATION_ROLES, + WORKFLOW_RUN_KINDS, WORKFLOW_RUN_STATUSES, - WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_PHASES, WORKFLOW_STEP_STATUSES, + WORKFLOW_STEP_TYPES, } from './constants' -export { - ACTION_FIRST_FRAME_CANDIDATE_COUNT, - CHARACTER_CANDIDATE_COUNT, - WORKFLOW_STEP_ORDERS, -} from './constants' - -/** ai/manual 表示运行由哪种交互方式推进,不改变后端数据契约。 */ -export type WorkflowDriver = (typeof WORKFLOW_DRIVERS)[number] - -/** Run 的用户目标,也是选择步骤模板和校验资产引用的判别字段。 */ -export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] - -/** 从两套步骤模板自动推导,避免类型与运行顺序手工维护两份。 */ -export type WorkflowStepType = - (typeof WORKFLOW_STEP_ORDERS)[keyof typeof WORKFLOW_STEP_ORDERS][number] -export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] -export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] +export type WorkflowRunKind = (typeof WORKFLOW_RUN_KINDS)[number] export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] -export type GenerationStatus = (typeof GENERATION_STATUSES)[number] -export type ExportStatus = (typeof EXPORT_STATUSES)[number] +export type WorkflowStepType = (typeof WORKFLOW_STEP_TYPES)[number] +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] +export type WorkflowStepPhase = (typeof WORKFLOW_STEP_PHASES)[number] +export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] /** - * 一次流程步骤的可恢复快照,不包含页面显示状态。 - * - * 此处故意没有通用 input/output:四张角色候选是后端临时文件,如果把 URL 塞入 - * localStorage,候选删除后就会留下无效历史。可恢复信息通过 taskId、正式资产 ID - * 和 referenceStepIds 表达,候选预览数组只存在当前界面/请求缓存中。 + * Step 对后端 GenerationTask 的引用。 + * 一个角色卡片对应一次四图生成;一个动作卡片可以对应四次首帧生成和一次动画生成。 + */ +export interface WorkflowGenerationRef { + taskId: Generation['id'] + role: WorkflowGenerationRole +} + +/** + * 一个 Step 就是编辑器中的一张卡片;phase 是卡片内部状态。 + * 这样展示层不需要把多个技术步骤重新拼装成一张卡片。 */ export interface WorkflowStep { - /** 前端步骤快照 ID,用于 Revision 之间引用;它不是后端 task ID。 */ id: string - /** 步骤业务类型;必须与当前 purpose 对应的模板位置一致。 */ + /** 内置流程使用稳定节点名;未来编辑器运行时保存 Definition 中的 nodeId。 */ + nodeId: string type: WorkflowStepType - /** 当前步骤在前端编排中的生命周期。 */ status: WorkflowStepStatus - /** - * 已由后端接受的 Generation ID。步骤 passed/failed 后仍保留, - * 方便历史查询和问题定位;它只是引用,不复制后端生成结果。 - */ - taskId: Generation['id'] | null - /** - * `first-frame` 一次需要 4 个独立生成任务,所以单独保存它们的 ID。 - * 其他步骤必须保持空数组;候选图 URL 仍不进入快照。 - */ - candidateTaskIds: Generation['id'][] - /** - * 请求已发出、但后端 taskId 尚未返回时的本地防重标识。 - * taskId 返回后必须清空,两者不能同时存在。 - */ - submissionId: string | null - /** 失败步骤必须提供原因,其他状态必须为 null。 */ + phase: WorkflowStepPhase + generations: WorkflowGenerationRef[] error: string | null - /** 新版本沿用的历史步骤,用于解释版本来源。 */ - referenceStepIds: string[] } -/** - * 一条可回看的任务执行版本。 - * - * 用户目标不变,只是从某个已通过步骤重做时,在同一 Run 下追加 Revision。 - * 网络重试不创建 Revision;用户改成另一个动作目标时则创建新 Run。 - */ -export interface WorkflowRevision { - /** 本版本 ID。 */ +/** 当前 PR 支持的内置流程来源。Workflow Editor 后续会扩展 definition 来源。 */ +export interface BuiltinWorkflowRunSource { + type: 'builtin' + key: WorkflowRunKind + rootNodeId: string +} + +export interface WorkflowCharacterInput { + prompt: string + referenceMedia: readonly MediaReference[] +} + +export interface WorkflowActionInput { id: string - /** 首版为 null;重做版本指向它沿用的旧 Revision。 */ - basedOnRevisionId: string | null - /** 首版为 null;重做时记录从旧 Revision 的哪个 passed 步骤重开。 */ - restartStepId: string | null - status: WorkflowRevisionStatus - steps: WorkflowStep[] - generationStatus: GenerationStatus - exportStatus: ExportStatus - createdAt: string + name: string + type: ActionType + prompt: string | null + fps: number } /** - * 两种任务共享的运行字段。 - * Run 是历史列表的主体;Revision 是 Run 内部的重做记录,不单独伪装成新任务。 + * 一次根任务的执行数据。 + * + * 这里故意没有 driver 和 revision:前者属于界面推进方式,后者如果用于编辑器版本, + * 应由独立的 WorkflowDefinition 表达。重做整个任务时创建新的 Run,而不是在 Run 内 + * 再维护一棵历史树。 */ -interface WorkflowRunBase { - /** 一次用户任务的稳定 ID,重做时不变。 */ +export interface WorkflowRunSnapshot { id: string - /** 所属项目;历史记录和恢复查询均按项目隔离。 */ projectId: string - driver: WorkflowDriver + source: BuiltinWorkflowRunSource status: WorkflowRunStatus - /** 当前可继续编辑的版本,必须存在于 revisions 中。 */ - currentRevisionId: string - /** 按创建时间排列;历史版本只读,重开时追加新版本。 */ - revisions: WorkflowRevision[] - /** 用户本次任务的目标描述;界面文案不存在这里。 */ - prompt: string | null - /** Run 创建时间,用于历史排序。 */ + steps: WorkflowStep[] + + characterInput: WorkflowCharacterInput | null + characterId: string | null + outfitId: string | null + characterSelectedAt: string | null + + actionInput: WorkflowActionInput | null + createdAt: string - /** 任何可持久业务状态最后更新的时间。 */ updatedAt: string } -/** - * 一次前端创作任务。当前由前端推进并用 localStorage 恢复,不伪装成已有后端持久化。 - * - * create_character 的两个分支表达同一个生命周期:生成中时还没有正式资产 ID; - * 用户从 4 张候选中选择 1 张且后端保存成功后,才同时写入 characterId、 - * outfitId 和 selectedAt。其余 3 张由后端清理,不进入 WorkflowRun。 - * - * add_action 是另一个 Run,只在用户点击“生成动作”时创建, - * 因此必须从开始就绑定已有 characterId 和 outfitId。它会先生成 - * 4 个独立首帧任务,用户选中 1 张后才进入完整动画生成。 - */ -export type WorkflowRun = WorkflowRunBase & - ( - | { - purpose: 'create_character' - characterId: null - outfitId: null - selectedAt: null - actionName?: never - actionType?: never - actionId?: never - fps?: never - } - | { - purpose: 'create_character' - characterId: string - outfitId: string - /** 选中图片已保存为正式角色资产的时间。 */ - selectedAt: string - actionName?: never - actionType?: never - actionId?: never - fps?: never - } - | { - purpose: 'add_action' - characterId: string - outfitId: string - selectedAt?: never - /** Run 创建时就固定的动作资产 ID,保证审核/发布重试幂等。 */ - actionId: string - /** 用户这次要创建的动作名称,刷新后仍用于正式写入资产。 */ - actionName: string - /** 动作的业务语义,不由生成结果反向猜测。 */ - actionType: ActionType - /** 最终动作资产的默认播放帧率。 */ - fps: number - } - ) - interface CreateWorkflowRunInputBase { - /** 任务所属项目,不允许空字符串。 */ projectId: string - /** 由 Quick Start 自动推进,或由工作流编辑器手动推进。 */ - driver: WorkflowDriver - /** 用户任务描述;Store 会去掉首尾空白,空文本按 null 保存。 */ - prompt?: string } -/** - * 创建 Run 的判别联合输入。 - * - * 创建角色时尚无资产 ID,所以类型明确禁止传入 characterId/outfitId; - * 追加动作必须定位已有角色的具体造型,所以两个 ID 缺一不可。 - */ +/** 创建一个根任务,而不是创建一个 GenerationTask。 */ export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & ( | { - purpose: 'create_character' - characterId?: never - outfitId?: never - actionName?: never - actionType?: never - actionId?: never - fps?: never + kind: 'character_action' + characterPrompt: string + referenceMedia?: readonly MediaReference[] } | { - purpose: 'add_action' + kind: 'add_action' characterId: string outfitId: string actionName: string actionType: ActionType + actionPrompt?: string | null fps: number } ) + +/** 角色确认后,在同一个 character_action Run 中配置后续动作卡片。 */ +export interface ConfigureWorkflowActionInput { + actionName: string + actionType: ActionType + actionPrompt?: string | null + fps: number +} diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts index 4b530d36..a0ea6a99 100644 --- a/frontend/src/entities/workflow-run/service/index.ts +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -8,15 +8,11 @@ export { createWorkflowRunService } from './workflow-run-service' export type { ActionFirstFrameCandidateBatch, - ActionReviewFrame, ActionReviewResult, CharacterCandidateBatch, CharacterCandidateConfirmationApis, - ConfirmCharacterSelectionInput, - ConfirmActionFirstFrameInput, CreateWorkflowRunServiceOptions, PublishActionResult, - StartActionRunInput, - StartCharacterRunInput, + WorkflowRun, WorkflowRunService, } from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index c4e2c9f6..8a98596f 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -1,12 +1,12 @@ -/** WorkflowRun Service 的真实用例链测试,不用伪造的页面成功状态代替端口结果。 */ - import { describe, expect, it, vi } from 'vitest' import type { Character, CharacterApis } from '../../character' -import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' -import { createWorkflowRunStore } from '../store' +import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import { createWorkflowRunRepository } from '../store' import { createWorkflowRunService, + type ActionFirstFrameCandidateBatch, + type CharacterCandidateBatch, type CharacterCandidateConfirmationApis, } from './workflow-run-service' @@ -14,8 +14,8 @@ function createCharacter(): Character { return { id: 'character-1', projectId: 'project-1', - createdAt: '2026-08-03T00:00:00.000Z', - updatedAt: '2026-08-03T00:00:00.000Z', + createdAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', outfits: [ { id: 'outfit-1', @@ -63,7 +63,7 @@ function createGenerationApis() { async get(_projectId, id) { const task = tasks.get(id) if (!task) throw new Error('任务不存在') - return task + return structuredClone(task) }, subscribe() { return () => undefined @@ -72,27 +72,17 @@ function createGenerationApis() { return { apis, create, tasks } } -function createService() { +function createFixture() { let id = 0 let timestamp = 0 - const store = createWorkflowRunStore({ - storage: null, - createId: () => `workflow-id-${++id}`, - now: () => `2026-08-03T00:00:${String(++timestamp).padStart(2, '0')}.000Z`, - }) + const repository = createWorkflowRunRepository({ storage: null }) const generation = createGenerationApis() let character = createCharacter() const characterApis: CharacterApis = { - get: vi.fn(async () => { - return structuredClone(character) - }), - async listByProject() { - return [structuredClone(character)] - }, - async create() { - return structuredClone(character) - }, - update: vi.fn(async (next: Character) => { + get: vi.fn(async () => structuredClone(character)), + listByProject: vi.fn(async () => [structuredClone(character)]), + create: vi.fn(async () => structuredClone(character)), + update: vi.fn(async (next) => { character = structuredClone(next) return structuredClone(character) }), @@ -103,356 +93,162 @@ function createService() { })) const candidateConfirmationApis: CharacterCandidateConfirmationApis = { confirmSelection } const service = createWorkflowRunService({ - store, + repository, generationApis: generation.apis, characterApis, candidateConfirmationApis, - now: () => `2026-08-03T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, + createId: () => `workflow-id-${++id}`, + now: () => `2026-08-05T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, }) - return { service, store, generation, characterApis, confirmSelection } + return { service, repository, generation, characterApis, confirmSelection } } -describe('createWorkflowRunService', () => { - it('runs character selection and action publishing as two linked user tasks', async () => { - const { service, store, generation, characterApis, confirmSelection } = createService() - - const candidates = await service.startCharacter({ +describe('WorkflowRun instance', () => { + it('uses one run and two card-aligned steps for character plus first action', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + kind: 'character_action', projectId: 'project-1', - prompt: '一位像素风守夜人', - driver: 'ai', + characterPrompt: '一位像素风守夜人', }) + const runId = run.id + expect(run.snapshot().steps.map((step) => step.type)).toEqual(['character', 'action']) - expect(candidates.candidates).toEqual([ - 'candidate-1.png', - 'candidate-2.png', - 'candidate-3.png', - 'candidate-4.png', - ]) - expect(candidates.run.purpose).toBe('create_character') - expect( - candidates.run.revisions[0]?.steps.find((step) => step.type === 'template-candidate')?.status, - ).toBe('active') - expect(JSON.stringify(store.get(candidates.run.id))).not.toContain('candidate-1.png') - - const characterRun = await service.confirmCharacter({ - runId: candidates.run.id, - selectedImageUrl: 'candidate-2.png', + const characters = (await run.start()) as CharacterCandidateBatch + expect(characters.candidates).toHaveLength(4) + expect(characters.snapshot.steps[0]).toMatchObject({ + type: 'character', + status: 'active', + phase: 'selecting_character', }) - expect(characterRun).toMatchObject({ - purpose: 'create_character', - status: 'completed', + expect(JSON.stringify(await fixture.repository.get(runId))).not.toContain('candidate-1.png') + + await run.confirmCharacter('candidate-2.png') + expect(run.snapshot()).toMatchObject({ + id: runId, characterId: 'character-1', outfitId: 'outfit-1', + status: 'active', }) - expect(confirmSelection).toHaveBeenCalledWith({ - projectId: 'project-1', - generationId: 'generation-1', - selectedImageUrl: 'candidate-2.png', - description: '一位像素风守夜人', + expect(run.snapshot().steps[1]).toMatchObject({ + type: 'action', + status: 'active', + phase: 'configuring_action', }) - const firstFrames = await service.startAction({ - projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', + run.configureAction({ actionName: '向前行走', actionType: 'walk', - prompt: '轻快地向前行走', + actionPrompt: '轻快地向前行走', fps: 12, - driver: 'ai', }) - - expect(firstFrames.run.id).not.toBe(characterRun.id) - expect(firstFrames.run.purpose).toBe('add_action') - expect(firstFrames.candidates).toEqual([ - 'first-frame-generation-2.png', - 'first-frame-generation-3.png', - 'first-frame-generation-4.png', - 'first-frame-generation-5.png', - ]) - expect( - firstFrames.run.revisions[0]?.steps.find((step) => step.type === 'first-frame-candidate') - ?.status, - ).toBe('active') - expect(JSON.stringify(store.get(firstFrames.run.id))).not.toContain('first-frame-generation') - expect(generation.create).toHaveBeenCalledTimes(5) - - const actionRun = await service.confirmActionFirstFrame({ - runId: firstFrames.run.id, - selectedImageUrl: firstFrames.candidates[1]!, - }) - expect(actionRun.revisions[0]?.steps.find((step) => step.type === 'review')?.status).toBe( - 'active', + const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch + expect(firstFrames.candidates).toHaveLength(4) + expect(firstFrames.snapshot.id).toBe(runId) + expect(firstFrames.snapshot.steps[1]).toMatchObject({ phase: 'selecting_action_frame' }) + + await run.confirmActionFirstFrame(firstFrames.candidates[1]!) + expect(run.snapshot().steps[1]).toMatchObject({ phase: 'reviewing_animation' }) + const published = await run.approveAction() + + expect(published.snapshot).toMatchObject({ id: runId, status: 'completed' }) + expect(published.snapshot.steps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'character', status: 'passed', phase: 'completed' }), + expect.objectContaining({ type: 'action', status: 'passed', phase: 'completed' }), + ]), ) - expect(generation.create).toHaveBeenCalledTimes(6) - - const review = await service.getActionReview(actionRun.id) - expect(review).toEqual({ - run: actionRun, - generationId: 'generation-6', - frames: [{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }], - }) - expect(store.get(actionRun.id)).toEqual(actionRun) - - const published = await service.approveAction(actionRun.id) - expect(published.run.status).toBe('completed') - expect(published.actionId).toBe(actionRun.actionId) expect(published.character.outfits[0]?.actions[0]).toMatchObject({ - id: actionRun.actionId, name: '向前行走', type: 'walk', fps: 12, }) - expect(published.character.outfits[0]?.actions[0]?.frames).toHaveLength(2) - expect(characterApis.update).toHaveBeenCalledTimes(1) - }) - - it('rejects a candidate that was not returned by the current generation task', async () => { - const { service, confirmSelection } = createService() - const batch = await service.startCharacter({ - projectId: 'project-1', - prompt: '角色', - driver: 'manual', - }) - - await expect( - service.confirmCharacter({ runId: batch.run.id, selectedImageUrl: 'foreign.png' }), - ).rejects.toThrow('选中图片不属于当前角色生成任务') - expect(confirmSelection).not.toHaveBeenCalled() - }) - - it('does not complete the character run when backend confirmation fails', async () => { - const fixture = createService() - fixture.confirmSelection.mockRejectedValueOnce(new Error('后端候选确认失败')) - const batch = await fixture.service.startCharacter({ - projectId: 'project-1', - prompt: '角色', - driver: 'ai', - }) - - await expect( - fixture.service.confirmCharacter({ - runId: batch.run.id, - selectedImageUrl: 'candidate-1.png', - }), - ).rejects.toThrow('后端候选确认失败') - expect(fixture.store.get(batch.run.id)?.status).toBe('active') - }) - - it('interrupts an active run and continues it without changing the active step', async () => { - const { service, store } = createService() - const batch = await service.startCharacter({ - projectId: 'project-1', - prompt: '角色', - driver: 'ai', - }) - const activeStepId = batch.run.revisions[0]?.steps.find((step) => step.status === 'active')?.id - - const interrupted = service.interruptRun(batch.run.id) - expect(interrupted.status).toBe('interrupted') - expect(interrupted.revisions[0]?.steps.find((step) => step.status === 'active')?.id).toBe( - activeStepId, - ) - expect(store.get(batch.run.id)?.status).toBe('interrupted') - - const resumed = service.continueRun(batch.run.id) - expect(resumed.status).toBe('active') - expect(resumed.revisions[0]?.steps.find((step) => step.status === 'active')?.id).toBe( - activeStepId, - ) - expect(store.get(batch.run.id)?.status).toBe('active') - }) - - it('rechecks the task after subscribing so a terminal event cannot be missed', async () => { - const fixture = createService() - const running: Generation<'character_template'> = { - id: 'generation-race', - projectId: 'project-1', - type: 'character_template', - status: 'running', - result: null, - error: null, - } - const completed: Generation<'character_template'> = { - ...running, - status: 'completed', - result: { - type: 'character_template', - images: [1, 2, 3, 4].map((index) => ({ url: `race-candidate-${index}.png` })), - }, - } - fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] - fixture.generation.apis.get = vi.fn(async () => completed) - fixture.generation.apis.subscribe = vi.fn(() => () => undefined) - - const batch = await fixture.service.startCharacter({ - projectId: 'project-1', - prompt: '竞态测试角色', - driver: 'ai', - }) - - expect(batch.candidates).toEqual([ - 'race-candidate-1.png', - 'race-candidate-2.png', - 'race-candidate-3.png', - 'race-candidate-4.png', - ]) - expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1) - expect(fixture.generation.apis.get).toHaveBeenCalledWith('project-1', 'generation-race') + expect(fixture.generation.create).toHaveBeenCalledTimes(6) + expect(fixture.confirmSelection).toHaveBeenCalledTimes(1) }) - it('does not advance an interrupted run when an in-flight generation finishes', async () => { - const fixture = createService() - const running: Generation<'character_template'> = { - id: 'generation-interrupted', + it('binds operations to the run instance and service only creates or restores instances', async () => { + const { service } = createFixture() + expect(Object.keys(service).sort()).toEqual(['create', 'get']) + const created = await service.create({ + kind: 'add_action', projectId: 'project-1', - type: 'character_template', - status: 'running', - result: null, - error: null, - } - let emit: (event: GenerationEvent) => void = () => { - throw new Error('生成订阅尚未建立') - } - fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] - fixture.generation.apis.get = vi.fn(async () => running) - fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { - emit = onEvent - return () => undefined - }) - - const pending = fixture.service.startCharacter({ - projectId: 'project-1', - prompt: '可中断角色', - driver: 'ai', - }) - await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) - const runId = fixture.store.list()[0]!.id - fixture.service.interruptRun(runId) - emit({ - taskId: running.id, - type: 'character_template', - status: 'completed', - result: { - type: 'character_template', - images: [1, 2, 3, 4].map((index) => ({ url: `late-candidate-${index}.png` })), - }, - error: null, + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '待机', + actionType: 'idle', + fps: 8, }) - - await expect(pending).rejects.toThrow('WorkflowRun 已中断,不能推进生成步骤') - const interrupted = fixture.store.get(runId)! - expect(interrupted.status).toBe('interrupted') - expect( - interrupted.revisions[0]?.steps.find((step) => step.type === 'character-template')?.status, - ).toBe('active') + const restored = await service.get(created.id) + expect(restored?.id).toBe(created.id) + expect(restored?.snapshot()).toEqual(created.snapshot()) }) - it('restores two persisted first-frame candidates and only creates the two missing tasks', async () => { - const { service, store, generation } = createService() - const run = store.create({ + it('keeps ordinary state transitions local until save is explicitly requested', async () => { + const { service, repository } = createFixture() + const run = await service.create({ + kind: 'add_action', projectId: 'project-1', - purpose: 'add_action', - driver: 'ai', - prompt: '向前行走', characterId: 'character-1', outfitId: 'outfit-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - const restored = structuredClone(run) - const revision = restored.revisions[0]! - revision.steps[0]!.status = 'passed' - revision.steps[1]!.status = 'active' - revision.steps[1]!.candidateTaskIds = ['persisted-first-frame-1', 'persisted-first-frame-2'] - revision.generationStatus = 'in_progress' - store.save(restored) - for (const index of [1, 2]) { - generation.tasks.set(`persisted-first-frame-${index}`, { - id: `persisted-first-frame-${index}`, - projectId: 'project-1', - type: 'first_frame', - status: 'completed', - result: { type: 'first_frame', image: { url: `restored-first-frame-${index}.png` } }, - error: null, - }) - } - - const resumed = await service.resumeActionFirstFrameCandidates(run.id) - - expect(generation.create).toHaveBeenCalledTimes(2) - expect(resumed.candidates).toHaveLength(4) - expect(resumed.candidates.slice(0, 2)).toEqual([ - 'restored-first-frame-1.png', - 'restored-first-frame-2.png', - ]) - expect(resumed.run.revisions[0]?.steps[2]?.type).toBe('first-frame-candidate') - expect(resumed.run.revisions[0]?.steps[2]?.status).toBe('active') + actionName: '待机', + actionType: 'idle', + fps: 8, + }) + + run.interrupt() + expect(run.snapshot().status).toBe('interrupted') + expect((await repository.get(run.id))?.status).toBe('active') + await run.save() + expect((await repository.get(run.id))?.status).toBe('interrupted') + run.continue() + expect(run.snapshot().status).toBe('active') }) - it('restores an action already in review without rerunning generation', async () => { - const { service, generation, characterApis } = createService() - const firstFrames = await service.startAction({ + it('rejects character and action candidates that do not belong to this run', async () => { + const { service, generation, confirmSelection } = createFixture() + const run = await service.create({ + kind: 'character_action', projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - driver: 'ai', - }) - const actionRun = await service.confirmActionFirstFrame({ - runId: firstFrames.run.id, - selectedImageUrl: firstFrames.candidates[0]!, + characterPrompt: '角色', }) - expect(generation.create).toHaveBeenCalledTimes(5) - expect(characterApis.get).toHaveBeenCalledTimes(2) - - const resumed = await service.resumeAction(actionRun.id) - const review = await service.getActionReview(actionRun.id) - - expect(resumed).toEqual(actionRun) - expect(review.frames).toEqual([{ imageUrl: 'frame-1.png' }, { imageUrl: 'frame-2.png' }]) - expect(generation.create).toHaveBeenCalledTimes(5) - expect(characterApis.get).toHaveBeenCalledTimes(2) - }) + await run.start() + await expect(run.confirmCharacter('foreign.png')).rejects.toThrow('不属于当前角色生成任务') + expect(confirmSelection).not.toHaveBeenCalled() - it('does not expose animation frames before the action reaches review', async () => { - const { service } = createService() - const firstFrames = await service.startAction({ + const actionRun = await service.create({ + kind: 'add_action', projectId: 'project-1', characterId: 'character-1', outfitId: 'outfit-1', actionName: '行走', actionType: 'walk', fps: 12, - driver: 'ai', }) - - await expect(service.getActionReview(firstFrames.run.id)).rejects.toThrow( - '动作尚未进入可审核状态', + await actionRun.start() + await expect(actionRun.confirmActionFirstFrame('foreign.png')).rejects.toThrow( + '不属于当前动作首帧任务', ) + expect(generation.create).toHaveBeenCalledTimes(5) }) - it('rejects a first-frame image that is not one of the four current candidates', async () => { - const { service, generation } = createService() - const firstFrames = await service.startAction({ + it('resumes an action from persisted GenerationTask references', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + kind: 'add_action', projectId: 'project-1', characterId: 'character-1', outfitId: 'outfit-1', actionName: '行走', actionType: 'walk', fps: 12, - driver: 'ai', }) + const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch + await run.confirmActionFirstFrame(firstFrames.candidates[0]!) - await expect( - service.confirmActionFirstFrame({ - runId: firstFrames.run.id, - selectedImageUrl: 'foreign-first-frame.png', - }), - ).rejects.toThrow('选中图片不属于当前动作首帧任务') - expect(generation.create).toHaveBeenCalledTimes(4) + const restored = (await fixture.service.get(run.id))! + const resumed = await restored.resumeAction() + expect(resumed.steps[0]).toMatchObject({ phase: 'reviewing_animation' }) + expect(fixture.generation.create).toHaveBeenCalledTimes(5) }) }) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 53e08bee..2dd7a24b 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -1,13 +1,6 @@ -/** - * WorkflowRun 的可执行前端用例。 - * - * Store 只保存快照,本 Service 才真正组合 Generation/Character 端口完成业务: - * 生成 4 张角色候选、确认 1 张为正式角色、创建独立动作 Run、 - * 生成 4 张动作首帧候选、根据选中首帧生成完整动画, - * 并在审核后写入角色资产。 - */ +/** WorkflowRun 的运行实例与用例入口。 */ -import type { Action, ActionType, Character, CharacterApis, Frame } from '../../character' +import type { Action, Character, CharacterApis, Frame } from '../../character' import type { CharacterTemplateGenerationResult, CompleteAnimationGenerationResult, @@ -15,17 +8,20 @@ import type { GenerationApis, GenerationEvent, } from '../../generation' -import type { MediaReference } from '../../media' import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' -import type { WorkflowRevision, WorkflowRun, WorkflowStep, WorkflowStepType } from '../model' -import type { WorkflowRunStore } from '../store' +import type { + ConfigureWorkflowActionInput, + CreateWorkflowRunInput, + WorkflowGenerationRole, + WorkflowRunSnapshot, + WorkflowStep, + WorkflowStepType, +} from '../model' +import type { WorkflowRunRepository } from '../store' /** * 确认角色候选的后端原子操作。 - * - * 后端必须在同一用例中保存选中图、返回正式角色/造型 ID, - * 并安排清理同一 generationId 下的其余 3 张候选。 - * 前端不能用“先创建角色、再单独删图”的两步请求伪装原子性。 + * 后端保存选中图并清理其余三个临时候选,前端只接收正式角色和造型 ID。 */ export interface CharacterCandidateConfirmationApis { confirmSelection(input: { @@ -36,669 +32,538 @@ export interface CharacterCandidateConfirmationApis { }): Promise<{ character: Character; outfitId: string }> } -export interface StartCharacterRunInput { - projectId: string - prompt: string - driver: 'ai' | 'manual' - referenceMedia?: readonly MediaReference[] -} - export interface CharacterCandidateBatch { - run: WorkflowRun + snapshot: WorkflowRunSnapshot generationId: string - /** 仅供当前选择界面使用,不写入 WorkflowRun/localStorage。 */ + /** 候选 URL 只用于当前选择界面,不写入 WorkflowRun。 */ candidates: readonly string[] } -export interface ConfirmCharacterSelectionInput { - runId: string - selectedImageUrl: string -} - -export interface StartActionRunInput { - projectId: string - characterId: string - outfitId: string - actionName: string - actionType: ActionType - prompt?: string | null - fps: number - driver: 'ai' | 'manual' -} - export interface ActionFirstFrameCandidateBatch { - run: WorkflowRun - /** 4 张图分别对应 4 个后端 Generation,顺序与 candidateTaskIds 一致。 */ + snapshot: WorkflowRunSnapshot candidateTaskIds: readonly string[] - /** 仅供当前首帧选择界面使用,不写入 WorkflowRun/localStorage。 */ + /** 候选 URL 只用于当前选择界面,不写入 WorkflowRun。 */ candidates: readonly string[] } -export interface ConfirmActionFirstFrameInput { - runId: string - selectedImageUrl: string -} - -/** - * 动作审核页真正需要的一帧。 - * - * 这里不直接把 Generation 的 DTO 暴露给页面:Generation 负责描述后端任务结果, - * WorkflowRun 则只交付当前任务已经确认可用于审核的图片地址。 - */ -export interface ActionReviewFrame { - imageUrl: string -} - -/** - * 完整动画生成结束后的只读审核结果。 - * - * generationId 让调用方能够定位本次完整动画任务;frames 的数组顺序就是播放顺序。 - * 读取该结果不会修改 Run,也不会把临时图片 URL 写进 WorkflowRun 快照。 - */ export interface ActionReviewResult { - /** 审核结果只可能属于动作任务,调用方无需再次判断 purpose。 */ - run: Extract + snapshot: WorkflowRunSnapshot generationId: string - frames: readonly ActionReviewFrame[] + frames: readonly { imageUrl: string }[] } export interface PublishActionResult { - run: WorkflowRun + snapshot: WorkflowRunSnapshot character: Character characterId: string outfitId: string actionId: string } +/** + * 绑定具体 Run 的运行对象。页面持有这个对象即可,不再同时传递 Service 和 runId。 + * `snapshot()` 返回可渲染数据;`save()` 是显式持久化边界。 + */ +export interface WorkflowRun { + readonly id: string + snapshot(): WorkflowRunSnapshot + save(): Promise + interrupt(): WorkflowRunSnapshot + continue(): WorkflowRunSnapshot + start(): Promise + resumeCharacterCandidates(): Promise + confirmCharacter(selectedImageUrl: string): Promise + configureAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot + resumeActionFirstFrameCandidates(): Promise + confirmActionFirstFrame(selectedImageUrl: string): Promise + resumeAction(): Promise + getActionReview(): Promise + approveAction(): Promise +} + +/** Service 只负责创建或恢复运行实例。 */ export interface WorkflowRunService { - /** 暂停进行中的 Run;当前 Revision 和 active 步骤保持不变。 */ - interruptRun(runId: string): WorkflowRun - /** 将已暂停的 Run 恢复为可执行状态;对 active Run 幂等。 */ - continueRun(runId: string): WorkflowRun - startCharacter(input: StartCharacterRunInput): Promise - resumeCharacterCandidates(runId: string): Promise - confirmCharacter(input: ConfirmCharacterSelectionInput): Promise - startAction(input: StartActionRunInput): Promise - resumeActionFirstFrameCandidates(runId: string): Promise - confirmActionFirstFrame(input: ConfirmActionFirstFrameInput): Promise - resumeAction(runId: string): Promise - getActionReview(runId: string): Promise - approveAction(runId: string): Promise + create(input: CreateWorkflowRunInput): Promise + get(runId: WorkflowRunSnapshot['id']): Promise } export interface CreateWorkflowRunServiceOptions { - store: WorkflowRunStore + repository: WorkflowRunRepository generationApis: GenerationApis characterApis: CharacterApis candidateConfirmationApis: CharacterCandidateConfirmationApis + createId?: () => string now?: () => string } -export function createWorkflowRunService({ - store, - generationApis, - characterApis, - candidateConfirmationApis, - now = () => new Date().toISOString(), -}: CreateWorkflowRunServiceOptions): WorkflowRunService { - function interruptRun(runId: string): WorkflowRun { - const run = requireRun(store, runId) - if (run.status === 'interrupted') return run - if (run.status !== 'active') throw new Error('只有进行中的 WorkflowRun 可以中断') - - const interrupted: WorkflowRun = { - ...run, - status: 'interrupted', - updatedAt: now(), - } - store.save(interrupted) - return interrupted - } +export function createWorkflowRunService( + options: CreateWorkflowRunServiceOptions, +): WorkflowRunService { + const createId = options.createId ?? createRandomId + const now = options.now ?? (() => new Date().toISOString()) - function continueRun(runId: string): WorkflowRun { - const run = requireRun(store, runId) - if (run.status === 'active') return run - if (run.status !== 'interrupted') throw new Error('只有已中断的 WorkflowRun 可以继续') + function bind(initial: WorkflowRunSnapshot): WorkflowRun { + let state = structuredClone(initial) - const active: WorkflowRun = { - ...run, - status: 'active', - updatedAt: now(), + const current = (): WorkflowRunSnapshot => structuredClone(state) + const replace = (next: WorkflowRunSnapshot): WorkflowRunSnapshot => { + state = structuredClone(next) + return current() } - store.save(active) - return active - } - - async function startCharacter(input: StartCharacterRunInput): Promise { - const prompt = input.prompt.trim() - if (!prompt) throw new Error('请先描述想要创建的角色') - - let run = store.create({ - projectId: input.projectId, - purpose: 'create_character', - driver: input.driver, - prompt, - }) - run = advanceStep(run, 'character-setup', 'character-template', now()) - store.save(run) - - try { - const generation = await generationApis.create({ - type: 'character_template', - projectId: run.projectId, - prompt, - referenceMedia: input.referenceMedia ?? [], - }) - // create() 等待期间用户可能已经中断 Run;写 taskId 前重新读取最新快照。 - run = recordTask(requireRun(store, run.id), 'character-template', generation.id, now()) - store.save(run) - const terminal = await waitForTerminal(generationApis, generation) - const result = requireCharacterCandidates(terminal) - run = completeGenerationStep( - requireRun(store, run.id), - 'character-template', - 'template-candidate', - now(), - ) - store.save(run) - return toCandidateBatch(run, terminal.id, result) - } catch (cause) { - failActiveRun(store, run.id, errorMessage(cause, '角色候选生成失败'), now()) - throw asError(cause) + const persist = async (): Promise => { + state = await options.repository.save(state) + return current() } - } - - async function resumeCharacterCandidates(runId: string): Promise { - let run = requireRun(store, runId) - if (run.purpose !== 'create_character') throw new Error('该 WorkflowRun 不是角色生成任务') - const templateStep = requireStep(run, 'character-template') - if (!templateStep.taskId) throw new Error('角色生成任务 ID 不存在,无法恢复候选') - - const terminal = await waitForTerminal( - generationApis, - await generationApis.get(run.projectId, templateStep.taskId), - ) - const result = requireCharacterCandidates(terminal) - run = requireRun(store, run.id) - if (requireStep(run, 'character-template').status === 'active') { - run = completeGenerationStep(run, 'character-template', 'template-candidate', now()) - store.save(run) - } - return toCandidateBatch(run, terminal.id, result) - } - - async function confirmCharacter(input: ConfirmCharacterSelectionInput): Promise { - const batch = await resumeCharacterCandidates(input.runId) - if (!batch.candidates.includes(input.selectedImageUrl)) { - throw new Error('选中图片不属于当前角色生成任务') - } - const run = batch.run - if (run.status !== 'active' || requireStep(run, 'template-candidate').status !== 'active') { - throw new Error('当前 WorkflowRun 不在候选确认阶段') - } - - const confirmed = await candidateConfirmationApis.confirmSelection({ - projectId: run.projectId, - generationId: batch.generationId, - selectedImageUrl: input.selectedImageUrl, - description: run.prompt ?? '', - }) - const outfit = confirmed.character.outfits.find((item) => item.id === confirmed.outfitId) - if ( - confirmed.character.projectId !== run.projectId || - !confirmed.character.id.trim() || - !outfit || - outfit.characterId !== confirmed.character.id - ) { - throw new Error('候选确认接口没有返回有效的角色与造型') + const mutate = (edit: (draft: WorkflowRunSnapshot) => void): WorkflowRunSnapshot => { + const draft = current() + edit(draft) + draft.updatedAt = now() + return replace(draft) } - - const selectedAt = now() - const completed = editCurrentRevision(run, selectedAt, (revision) => { - const candidate = revision.steps.find((step) => step.type === 'template-candidate')! - candidate.status = 'passed' - revision.status = 'completed' - revision.generationStatus = 'completed' - }) as WorkflowRun - if (completed.purpose !== 'create_character') { - throw new Error('角色确认过程中 WorkflowRun 目的发生了变化') - } - const result: WorkflowRun = { - ...completed, - purpose: 'create_character', - status: 'completed', - characterId: confirmed.character.id, - outfitId: confirmed.outfitId, - selectedAt, - updatedAt: selectedAt, - } - store.save(result) - return result - } - - async function startAction(input: StartActionRunInput): Promise { - if (!input.actionName.trim()) throw new Error('请先填写动作名称') - if (!Number.isFinite(input.fps) || input.fps <= 0) throw new Error('FPS 必须大于 0') - - // 在创建 Run 前校验正式角色,避免错误 ID 留下永远无法继续的空历史。 - const characterImageUrl = await loadCharacterImage( - input.projectId, - input.characterId, - input.outfitId, - ) - - let run = store.create({ - projectId: input.projectId, - purpose: 'add_action', - driver: input.driver, - prompt: input.prompt?.trim() || undefined, - characterId: input.characterId, - outfitId: input.outfitId, - actionName: input.actionName.trim(), - actionType: input.actionType, - fps: input.fps, - }) - run = advanceStep(run, 'action-setup', 'first-frame', now()) - store.save(run) - - try { - return await collectActionFirstFrameCandidates(run.id, characterImageUrl) - } catch (cause) { - failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选生成失败'), now()) - throw asError(cause) + const checkpoint = async (edit: (draft: WorkflowRunSnapshot) => void) => { + mutate(edit) + return persist() } - } - async function resumeActionFirstFrameCandidates( - runId: string, - ): Promise { - const run = requireRun(store, runId) - if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') - if (run.status !== 'active') throw new Error('动作任务已经结束,无法恢复首帧候选') - const characterImageUrl = await loadCharacterImage(run.projectId, run.characterId, run.outfitId) - try { - return await collectActionFirstFrameCandidates(run.id, characterImageUrl) - } catch (cause) { - failActiveRun(store, run.id, errorMessage(cause, '动作首帧候选恢复失败'), now()) - throw asError(cause) + async function fail(message: string): Promise { + if (state.status !== 'active') return + mutate((draft) => { + const step = requireActiveStep(draft) + step.status = 'failed' + step.error = message + draft.status = 'failed' + }) + await persist() } - } - - async function loadCharacterImage( - projectId: string, - characterId: string, - outfitId: string, - ): Promise { - const character = await characterApis.get(characterId) - if (character.projectId !== projectId) throw new Error('动作角色不属于当前项目') - const outfit = character.outfits.find((item) => item.id === outfitId) - if (!outfit || outfit.characterId !== characterId) throw new Error('动作所属角色造型不存在') - if (!outfit.characterTemplateUrl) throw new Error('正式角色造型没有可用的角色图') - return outfit.characterTemplateUrl - } - async function collectActionFirstFrameCandidates( - runId: string, - characterImageUrl: string, - ): Promise { - let run = requireRun(store, runId) - if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') - let firstFrameStep = requireStep(run, 'first-frame') - - if (firstFrameStep.status === 'active') { - while (firstFrameStep.candidateTaskIds.length < ACTION_FIRST_FRAME_CANDIDATE_COUNT) { - if (run.purpose !== 'add_action') { - throw new Error('动作首帧生成过程中 WorkflowRun 目的发生了变化') + async function generateCharacterCandidates(): Promise { + assertActive(state) + const step = requireStep(state, 'character') + if (step.phase === 'selecting_character') return loadCharacterCandidates() + if (step.phase !== 'generating_character_candidates') { + throw new Error('角色卡片当前不能生成候选图') + } + const input = state.characterInput + if (!input) throw new Error('WorkflowRun 缺少角色生成输入') + + try { + let generationId = generationIdFor(step, 'character_candidates') + if (!generationId) { + const generation = await options.generationApis.create({ + type: 'character_template', + projectId: state.projectId, + prompt: input.prompt, + referenceMedia: input.referenceMedia, + }) + generationId = generation.id + await checkpoint((draft) => { + requireStep(draft, 'character').generations.push({ + taskId: generation.id, + role: 'character_candidates', + }) + }) } - const task = await generationApis.create({ - type: 'first_frame', - projectId: run.projectId, - characterId: run.characterId, - outfitId: run.outfitId, - actionType: run.actionType, - prompt: run.prompt, - referenceMedia: [characterImageUrl as MediaReference], + const terminal = await waitForTerminal( + options.generationApis, + await options.generationApis.get(state.projectId, generationId), + ) + const result = requireCharacterCandidates(terminal) + assertActive(state) + await checkpoint((draft) => { + requireStep(draft, 'character').phase = 'selecting_character' }) - // 每个候选请求都可能跨过一次用户中断,不能用请求前的旧快照继续推进。 - run = appendCandidateTask(requireRun(store, run.id), 'first-frame', task.id, now()) - store.save(run) - firstFrameStep = requireStep(run, 'first-frame') + return toCharacterBatch(state, generationId, result) + } catch (cause) { + await fail(errorMessage(cause, '角色候选生成失败')) + throw asError(cause) } - } else if ( - firstFrameStep.status !== 'passed' || - requireStep(run, 'first-frame-candidate').status !== 'active' - ) { - throw new Error('当前 WorkflowRun 不在动作首帧选择阶段') } - const taskIds = requireStep(run, 'first-frame').candidateTaskIds - const terminals = await Promise.all( - taskIds.map(async (taskId) => - waitForTerminal(generationApis, await generationApis.get(run.projectId, taskId)), - ), - ) - const candidates = terminals.map(requireFirstFrame) - run = requireRun(store, run.id) - firstFrameStep = requireStep(run, 'first-frame') - if (firstFrameStep.status === 'active') { - run = completeGenerationStep(run, 'first-frame', 'first-frame-candidate', now()) - store.save(run) + async function loadCharacterCandidates(): Promise { + const step = requireStep(state, 'character') + const generationId = generationIdFor(step, 'character_candidates') + if (!generationId) throw new Error('角色候选任务 ID 不存在') + const result = requireCharacterCandidates( + await options.generationApis.get(state.projectId, generationId), + ) + return toCharacterBatch(state, generationId, result) } - return { run, candidateTaskIds: taskIds, candidates } - } - async function confirmActionFirstFrame( - input: ConfirmActionFirstFrameInput, - ): Promise { - const batch = await resumeActionFirstFrameCandidates(input.runId) - if (!batch.candidates.includes(input.selectedImageUrl)) { - throw new Error('选中图片不属于当前动作首帧任务') + async function collectActionCandidates(): Promise { + assertActive(state) + const step = requireStep(state, 'action') + if (step.phase === 'selecting_action_frame') return loadActionCandidates() + if (step.phase !== 'generating_action_candidates') { + throw new Error('动作卡片当前不能生成首帧候选') + } + const action = requireActionInput(state) + const { characterId, outfitId } = requireCharacterBinding(state) + const character = await options.characterApis.get(characterId) + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit?.characterTemplateUrl) throw new Error('动作生成需要已确认的角色母版') + + try { + while ( + generationIdsFor(requireStep(state, 'action'), 'action_frame_candidate').length < + ACTION_FIRST_FRAME_CANDIDATE_COUNT + ) { + const generation = await options.generationApis.create({ + type: 'first_frame', + projectId: state.projectId, + characterId, + outfitId, + actionType: action.type, + prompt: action.prompt, + referenceMedia: [], + }) + await checkpoint((draft) => { + requireStep(draft, 'action').generations.push({ + taskId: generation.id, + role: 'action_frame_candidate', + }) + }) + } + + const batch = await loadActionCandidates() + assertActive(state) + await checkpoint((draft) => { + requireStep(draft, 'action').phase = 'selecting_action_frame' + }) + return { ...batch, snapshot: current() } + } catch (cause) { + await fail(errorMessage(cause, '动作首帧候选生成失败')) + throw asError(cause) + } } - const run = batch.run - if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') - try { - const animationTask = await generationApis.create({ - type: 'complete_animation', - projectId: run.projectId, - characterId: run.characterId, - outfitId: run.outfitId, - actionType: run.actionType, - firstFrameUrl: input.selectedImageUrl, - prompt: run.prompt, - referenceMedia: [], - }) - const generating = startAnimationFromCandidate( - requireRun(store, run.id), - animationTask.id, - now(), - ) - store.save(generating) - const terminal = await waitForTerminal(generationApis, animationTask) - requireAnimation(terminal) - const completed = completeGenerationStep( - requireRun(store, run.id), - 'complete-animation', - 'review', - now(), + async function loadActionCandidates(): Promise { + const step = requireStep(state, 'action') + const taskIds = generationIdsFor(step, 'action_frame_candidate') + if (taskIds.length !== ACTION_FIRST_FRAME_CANDIDATE_COUNT) { + throw new Error(`动作首帧必须包含 ${ACTION_FIRST_FRAME_CANDIDATE_COUNT} 个候选任务`) + } + const candidates = await Promise.all( + taskIds.map(async (taskId) => { + const terminal = await waitForTerminal( + options.generationApis, + await options.generationApis.get(state.projectId, taskId), + ) + return requireFirstFrame(terminal) + }), ) - store.save(completed) - return completed - } catch (cause) { - failActiveRun(store, run.id, errorMessage(cause, '完整动画生成失败'), now()) - throw asError(cause) + return { snapshot: current(), candidateTaskIds: taskIds, candidates } } - } - async function resumeAction(runId: string): Promise { - let run = requireRun(store, runId) - if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') - if (run.status !== 'active' || requireStep(run, 'review').status === 'active') return run - const animationStep = requireStep(run, 'complete-animation') - if (animationStep.status !== 'active') return run - if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在,无法恢复') - - try { - const terminal = await waitForTerminal( - generationApis, - await generationApis.get(run.projectId, animationStep.taskId), - ) - requireAnimation(terminal) - run = completeGenerationStep(requireRun(store, run.id), 'complete-animation', 'review', now()) - store.save(run) - return run - } catch (cause) { - failActiveRun(store, run.id, errorMessage(cause, '完整动画恢复失败'), now()) - throw asError(cause) + const run: WorkflowRun = { + id: state.id, + snapshot: current, + save: persist, + interrupt() { + if (state.status !== 'active') throw new Error('只有进行中的 WorkflowRun 可以中断') + return mutate((draft) => { + draft.status = 'interrupted' + }) + }, + continue() { + if (state.status !== 'interrupted') throw new Error('只有已中断的 WorkflowRun 可以继续') + return mutate((draft) => { + draft.status = 'active' + }) + }, + async start() { + const step = requireActiveStep(state) + return step.type === 'character' ? generateCharacterCandidates() : collectActionCandidates() + }, + resumeCharacterCandidates: generateCharacterCandidates, + async confirmCharacter(selectedImageUrl) { + assertActive(state) + const step = requireStep(state, 'character') + if (step.phase !== 'selecting_character') throw new Error('角色尚未进入候选选择阶段') + const batch = await loadCharacterCandidates() + if (!batch.candidates.includes(selectedImageUrl)) { + throw new Error('选中图片不属于当前角色生成任务') + } + const characterInput = state.characterInput + if (!characterInput) throw new Error('WorkflowRun 缺少角色生成输入') + const confirmed = await options.candidateConfirmationApis.confirmSelection({ + projectId: state.projectId, + generationId: batch.generationId, + selectedImageUrl, + description: characterInput.prompt, + }) + return checkpoint((draft) => { + const characterStep = requireStep(draft, 'character') + characterStep.status = 'passed' + characterStep.phase = 'completed' + draft.characterId = confirmed.character.id + draft.outfitId = confirmed.outfitId + draft.characterSelectedAt = now() + const actionStep = requireStep(draft, 'action') + actionStep.status = 'active' + actionStep.phase = 'configuring_action' + }) + }, + configureAction(input) { + assertActive(state) + const step = requireStep(state, 'action') + if (step.phase !== 'configuring_action') throw new Error('动作卡片当前不能配置') + validateActionInput(input) + return mutate((draft) => { + draft.actionInput = { + id: createId(), + name: input.actionName.trim(), + type: input.actionType, + prompt: input.actionPrompt?.trim() || null, + fps: input.fps, + } + requireStep(draft, 'action').phase = 'generating_action_candidates' + }) + }, + resumeActionFirstFrameCandidates: collectActionCandidates, + async confirmActionFirstFrame(selectedImageUrl) { + assertActive(state) + const step = requireStep(state, 'action') + if (step.phase !== 'selecting_action_frame') { + throw new Error('动作尚未进入首帧选择阶段') + } + const batch = await loadActionCandidates() + if (!batch.candidates.includes(selectedImageUrl)) { + throw new Error('选中图片不属于当前动作首帧任务') + } + const action = requireActionInput(state) + const { characterId, outfitId } = requireCharacterBinding(state) + try { + const generation = await options.generationApis.create({ + type: 'complete_animation', + projectId: state.projectId, + characterId, + outfitId, + actionType: action.type, + firstFrameUrl: selectedImageUrl, + prompt: action.prompt, + referenceMedia: [], + }) + await checkpoint((draft) => { + const actionStep = requireStep(draft, 'action') + actionStep.generations.push({ taskId: generation.id, role: 'animation' }) + actionStep.phase = 'generating_animation' + }) + return run.resumeAction() + } catch (cause) { + await fail(errorMessage(cause, '完整动画生成失败')) + throw asError(cause) + } + }, + async resumeAction() { + assertActive(state) + const step = requireStep(state, 'action') + if (step.phase === 'reviewing_animation') return current() + if (step.phase !== 'generating_animation') throw new Error('动作当前不在动画生成阶段') + const taskId = generationIdFor(step, 'animation') + if (!taskId) throw new Error('完整动画任务 ID 不存在') + try { + requireAnimation( + await waitForTerminal( + options.generationApis, + await options.generationApis.get(state.projectId, taskId), + ), + ) + assertActive(state) + return checkpoint((draft) => { + requireStep(draft, 'action').phase = 'reviewing_animation' + }) + } catch (cause) { + await fail(errorMessage(cause, '完整动画恢复失败')) + throw asError(cause) + } + }, + async getActionReview() { + const step = requireStep(state, 'action') + if (state.status !== 'active' || step.phase !== 'reviewing_animation') { + throw new Error('动作尚未进入可审核状态') + } + const generationId = generationIdFor(step, 'animation') + if (!generationId) throw new Error('完整动画任务 ID 不存在') + const animation = requireAnimation( + await options.generationApis.get(state.projectId, generationId), + ) + return { + snapshot: current(), + generationId, + frames: animation.frames.map((frame) => ({ imageUrl: frame.url })), + } + }, + async approveAction() { + const review = await run.getActionReview() + const actionInput = requireActionInput(state) + const { characterId, outfitId } = requireCharacterBinding(state) + const character = await options.characterApis.get(characterId) + const outfit = character.outfits.find((item) => item.id === outfitId) + if (!outfit) throw new Error('动作所属造型不存在') + const action: Action = { + id: actionInput.id, + outfitId, + name: actionInput.name, + kind: 'custom', + type: actionInput.type, + fps: actionInput.fps, + keyFrameIndex: null, + frames: review.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + durationMs: null, + rootMotion: null, + })), + } + const savedCharacter = await options.characterApis.update({ + ...character, + outfits: character.outfits.map((item) => + item.id === outfitId + ? { + ...item, + actions: [ + ...item.actions.filter((existing) => existing.id !== actionInput.id), + action, + ], + } + : item, + ), + }) + await checkpoint((draft) => { + const actionStep = requireStep(draft, 'action') + actionStep.phase = 'completed' + actionStep.status = 'passed' + draft.status = 'completed' + }) + return { + snapshot: current(), + character: savedCharacter, + characterId, + outfitId, + actionId: actionInput.id, + } + }, } + return run } - async function getActionReview(runId: string): Promise { - const run = requireRun(store, runId) - if (run.purpose !== 'add_action') throw new Error('该 WorkflowRun 不是动作任务') - if (run.status !== 'active' || requireStep(run, 'review').status !== 'active') { - throw new Error('动作尚未进入可审核状态') - } - const animationStep = requireStep(run, 'complete-animation') - if (!animationStep.taskId) throw new Error('完整动画任务 ID 不存在') - const animation = requireAnimation( - await generationApis.get(run.projectId, animationStep.taskId), - ) - - return { - run, - generationId: animationStep.taskId, - frames: animation.frames.map((frame) => ({ imageUrl: frame.url })), - } + return { + async create(input) { + const state = createInitialSnapshot(input, createId, now) + return bind(await options.repository.create(state)) + }, + async get(runId) { + const state = await options.repository.get(runId) + return state ? bind(state) : null + }, } +} - async function approveAction(runId: string): Promise { - // 审核页展示和最终写入角色必须读取同一份、经过同一套校验的动画结果。 - // 这样可以避免页面看见一组帧,点击通过后却导入另一组帧。 - const review = await getActionReview(runId) - const run = review.run - - const character = await characterApis.get(run.characterId) - const outfit = character.outfits.find((item) => item.id === run.outfitId) - if (!outfit) throw new Error('动作所属造型不存在') - const action: Action = { - id: run.actionId, - outfitId: outfit.id, - name: run.actionName, - kind: 'custom', - type: run.actionType, - fps: run.fps, - keyFrameIndex: null, - frames: review.frames.map((frame) => ({ - imageUrl: frame.imageUrl, - durationMs: null, - rootMotion: null, - })), - } - const saved = await characterApis.update({ - ...character, - outfits: character.outfits.map((item) => - item.id === outfit.id - ? { - ...item, - actions: [...item.actions.filter((existing) => existing.id !== run.actionId), action], - } - : item, - ), - }) - - const completedAt = now() - const completed = editCurrentRevision(run, completedAt, (revision) => { - requireRevisionStep(revision, 'review').status = 'passed' - requireRevisionStep(revision, 'export').status = 'passed' - revision.status = 'completed' - revision.exportStatus = 'exported' - }) as WorkflowRun - const result: WorkflowRun = { - ...completed, - status: 'completed', - updatedAt: completedAt, - } - store.save(result) - return { - run: result, - character: saved, - characterId: run.characterId, - outfitId: run.outfitId, - actionId: run.actionId, - } +function createInitialSnapshot( + input: CreateWorkflowRunInput, + createId: () => string, + now: () => string, +): WorkflowRunSnapshot { + if (!input.projectId.trim()) throw new TypeError('projectId 不能为空') + if (input.kind === 'character_action' && !input.characterPrompt.trim()) { + throw new TypeError('角色描述不能为空') } + if (input.kind === 'add_action') validateActionInput(input) + + const createdAt = now() + const characterStep = (): WorkflowStep => ({ + id: createId(), + nodeId: 'builtin-character', + type: 'character', + status: 'active', + phase: 'generating_character_candidates', + generations: [], + error: null, + }) + const actionStep = (active: boolean): WorkflowStep => ({ + id: createId(), + nodeId: 'builtin-action', + type: 'action', + status: active ? 'active' : 'locked', + phase: active ? 'generating_action_candidates' : 'configuring_action', + generations: [], + error: null, + }) + const steps = + input.kind === 'character_action' ? [characterStep(), actionStep(false)] : [actionStep(true)] return { - interruptRun, - continueRun, - startCharacter, - resumeCharacterCandidates, - confirmCharacter, - startAction, - resumeActionFirstFrameCandidates, - confirmActionFirstFrame, - resumeAction, - getActionReview, - approveAction, + id: createId(), + projectId: input.projectId.trim(), + source: { type: 'builtin', key: input.kind, rootNodeId: steps[0]!.nodeId }, + status: 'active', + steps, + characterInput: + input.kind === 'character_action' + ? { prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [] } + : null, + characterId: input.kind === 'add_action' ? input.characterId.trim() : null, + outfitId: input.kind === 'add_action' ? input.outfitId.trim() : null, + characterSelectedAt: input.kind === 'add_action' ? createdAt : null, + actionInput: + input.kind === 'add_action' + ? { + id: createId(), + name: input.actionName.trim(), + type: input.actionType, + prompt: input.actionPrompt?.trim() || null, + fps: input.fps, + } + : null, + createdAt, + updatedAt: createdAt, } } -function requireRun(store: WorkflowRunStore, runId: string): WorkflowRun { - const run = store.get(runId) - if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) - return run -} - -function currentRevision(run: WorkflowRun): WorkflowRevision { - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') - return revision +function validateActionInput( + input: ConfigureWorkflowActionInput | Extract, +): void { + if (!input.actionName.trim()) throw new TypeError('动作名称不能为空') + if (!Number.isFinite(input.fps) || input.fps <= 0) throw new TypeError('FPS 必须大于 0') + if ('characterId' in input && (!input.characterId.trim() || !input.outfitId.trim())) { + throw new TypeError('追加动作必须绑定角色和造型') + } } -function requireRevisionStep(revision: WorkflowRevision, type: WorkflowStepType): WorkflowStep { - const step = revision.steps.find((item) => item.type === type) - if (!step) throw new Error(`WorkflowRun 缺少 ${type} 步骤`) +function requireStep(run: WorkflowRunSnapshot, type: WorkflowStepType): WorkflowStep { + const step = run.steps.find((item) => item.type === type) + if (!step) throw new Error(`WorkflowRun 缺少 ${type} 卡片`) return step } -function requireStep(run: WorkflowRun, type: WorkflowStepType): WorkflowStep { - return requireRevisionStep(currentRevision(run), type) -} - -function editCurrentRevision( - run: WorkflowRun, - updatedAt: string, - edit: (revision: WorkflowRevision) => void, -): WorkflowRun { - const next = structuredClone(run) - edit(currentRevision(next)) - next.updatedAt = updatedAt - return next -} - -function advanceStep( - run: WorkflowRun, - currentType: WorkflowStepType, - nextType: WorkflowStepType, - updatedAt: string, -): WorkflowRun { - return editCurrentRevision(run, updatedAt, (revision) => { - const current = requireRevisionStep(revision, currentType) - const next = requireRevisionStep(revision, nextType) - if (current.status !== 'active' || next.status !== 'locked') { - throw new Error(`不能从 ${currentType} 推进到 ${nextType}`) - } - current.status = 'passed' - next.status = 'active' - }) +function requireActiveStep(run: WorkflowRunSnapshot): WorkflowStep { + const step = run.steps.find((item) => item.status === 'active') + if (!step) throw new Error('WorkflowRun 没有当前活动卡片') + return step } -function recordTask( - run: WorkflowRun, - type: WorkflowStepType, - taskId: string, - updatedAt: string, -): WorkflowRun { - if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能记录生成任务') - return editCurrentRevision(run, updatedAt, (revision) => { - const step = requireRevisionStep(revision, type) - if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) - step.taskId = taskId - step.submissionId = null - revision.generationStatus = 'in_progress' - }) +function assertActive(run: WorkflowRunSnapshot): void { + if (run.status !== 'active') throw new Error('WorkflowRun 当前不能继续推进') } -/** - * 每次后端成功返回一个首帧任务 ID 就立即保存。 - * 如果第 3 个请求时页面刷新,恢复后只需补齐缺少的任务, - * 不会重复提交前两个。 - */ -function appendCandidateTask( - run: WorkflowRun, - type: WorkflowStepType, - taskId: string, - updatedAt: string, -): WorkflowRun { - if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能追加首帧候选') - return editCurrentRevision(run, updatedAt, (revision) => { - const step = requireRevisionStep(revision, type) - if (step.status !== 'active') throw new Error(`${type} 步骤未激活`) - if (step.candidateTaskIds.includes(taskId)) throw new Error('首帧候选任务 ID 重复') - if (step.candidateTaskIds.length >= ACTION_FIRST_FRAME_CANDIDATE_COUNT) { - throw new Error('首帧候选任务数量已达上限') - } - step.candidateTaskIds.push(taskId) - step.submissionId = null - revision.generationStatus = 'in_progress' - }) +function requireCharacterBinding(run: WorkflowRunSnapshot): { + characterId: string + outfitId: string +} { + if (!run.characterId || !run.outfitId) throw new Error('WorkflowRun 尚未绑定角色和造型') + return { characterId: run.characterId, outfitId: run.outfitId } } -/** 选中首帧后,把候选步骤和完整动画 taskId 一次写入同一份快照。 */ -function startAnimationFromCandidate( - run: WorkflowRun, - taskId: string, - updatedAt: string, -): WorkflowRun { - if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能开始完整动画') - return editCurrentRevision(run, updatedAt, (revision) => { - const candidate = requireRevisionStep(revision, 'first-frame-candidate') - const animation = requireRevisionStep(revision, 'complete-animation') - if (candidate.status !== 'active' || animation.status !== 'locked') { - throw new Error('当前 WorkflowRun 不能从首帧候选进入完整动画') - } - candidate.status = 'passed' - animation.status = 'active' - animation.taskId = taskId - animation.submissionId = null - revision.generationStatus = 'in_progress' - }) +function requireActionInput(run: WorkflowRunSnapshot) { + if (!run.actionInput) throw new Error('WorkflowRun 尚未配置动作') + return run.actionInput } -function completeGenerationStep( - run: WorkflowRun, - currentType: WorkflowStepType, - nextType: WorkflowStepType, - updatedAt: string, -): WorkflowRun { - if (run.status !== 'active') throw new Error('WorkflowRun 已中断,不能推进生成步骤') - return editCurrentRevision(run, updatedAt, (revision) => { - const current = requireRevisionStep(revision, currentType) - const next = requireRevisionStep(revision, nextType) - const hasGenerationTasks = - current.taskId !== null || - (current.type === 'first-frame' && - current.candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) - if (current.status !== 'active' || !hasGenerationTasks || next.status !== 'locked') { - throw new Error(`${currentType} 步骤没有可完成的生成任务`) - } - current.status = 'passed' - next.status = 'active' - revision.generationStatus = nextType === 'complete-animation' ? 'in_progress' : 'completed' - }) +function generationIdsFor(step: WorkflowStep, role: WorkflowGenerationRole): string[] { + return step.generations.filter((item) => item.role === role).map((item) => item.taskId) } -function failActiveRun( - store: WorkflowRunStore, - runId: string, - message: string, - updatedAt: string, -): void { - const existing = store.get(runId) - if (!existing || existing.status !== 'active') return - const failed = editCurrentRevision(existing, updatedAt, (revision) => { - const active = revision.steps.find((step) => step.status === 'active') - if (active) { - active.status = 'failed' - active.error = message - active.submissionId = null - } - revision.status = 'failed' - revision.generationStatus = 'failed' - }) - failed.status = 'failed' - store.save(failed) +function generationIdFor(step: WorkflowStep, role: WorkflowGenerationRole): string | null { + return generationIdsFor(step, role)[0] ?? null } function requireCharacterCandidates(generation: Generation): CharacterTemplateGenerationResult { @@ -742,12 +607,16 @@ function requireFirstFrame(generation: Generation): string { return generation.result.image.url } -function toCandidateBatch( - run: WorkflowRun, +function toCharacterBatch( + snapshot: WorkflowRunSnapshot, generationId: string, result: CharacterTemplateGenerationResult, ): CharacterCandidateBatch { - return { run, generationId, candidates: result.images.map((image) => image.url) } + return { + snapshot: structuredClone(snapshot), + generationId, + candidates: result.images.map((image) => image.url), + } } function waitForTerminal( @@ -772,7 +641,7 @@ function waitForTerminal( stop() resolve(snapshot) } - const settle = (event: GenerationEvent) => { + const settleEvent = (event: GenerationEvent) => { settleGeneration({ id: event.taskId, projectId: generation.projectId, @@ -783,12 +652,8 @@ function waitForTerminal( }) } try { - stop = generationApis.subscribe(generation.projectId, generation.id, settle) - if (settled) { - stop() - return - } - // 先订阅再复查快照,封住“首次 get 尚未完成、subscribe 前已完成”的竞态窗口。 + stop = generationApis.subscribe(generation.projectId, generation.id, settleEvent) + if (settled) return stop() void generationApis.get(generation.projectId, generation.id).then(settleGeneration, fail) } catch (cause) { fail(cause) @@ -796,6 +661,13 @@ function waitForTerminal( }) } +function createRandomId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return `workflow-${Date.now()}-${Math.random().toString(16).slice(2)}` +} + function errorMessage(cause: unknown, fallback: string): string { return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback } diff --git a/frontend/src/entities/workflow-run/store/index.ts b/frontend/src/entities/workflow-run/store/index.ts index 75ca2234..c65cee14 100644 --- a/frontend/src/entities/workflow-run/store/index.ts +++ b/frontend/src/entities/workflow-run/store/index.ts @@ -1,14 +1,12 @@ -/** - * WorkflowRun 本地仓库的子目录入口。 - * - * 本目录回答“WorkflowRun 在当前前端怎样创建、校验、保存和通知”。 - * 它依赖 model,但 model 不反向依赖 Store。后续接入服务器持久化时, - * 可替换这层的适配实现,不需改变 WorkflowRun 领域类型。 - */ +/** WorkflowRun 的异步持久化边界。 */ export { - createWorkflowRunStore, + createWorkflowRunRepository, + isWorkflowRunSnapshot, WORKFLOW_RUN_STORAGE_KEY, WORKFLOW_RUN_STORAGE_VERSION, } from './workflow-run-store' -export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './workflow-run-store' +export type { + CreateWorkflowRunRepositoryOptions, + WorkflowRunRepository, +} from './workflow-run-store' diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index acb4f2aa..85646eae 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -1,464 +1,120 @@ -/** - * WorkflowRun Store 的可执行业务规则。 - * - * 这些测试不是在验证页面点击,而是锁定数据层不得破坏的契约: - * 角色/动作任务的步骤必须分开,完成角色任务前必须有正式资产, - * 临时候选不得进入持久化快照,Revision 历史引用不得悬空。 - */ +import { describe, expect, it } from 'vitest' -import { describe, expect, it, vi } from 'vitest' - -import type { WorkflowRevision, WorkflowRun, WorkflowRunPurpose, WorkflowStep } from '../model' +import type { WorkflowRunSnapshot } from '../model' import { - createWorkflowRunStore, - WORKFLOW_RUN_STORAGE_KEY, + createWorkflowRunRepository, + isWorkflowRunSnapshot, WORKFLOW_RUN_STORAGE_VERSION, } from './workflow-run-store' -import { CHARACTER_CANDIDATE_COUNT, WORKFLOW_STEP_ORDERS } from '../model/constants' - -/** 最小 localStorage 替身:既可观察序列化结果,也可主动模拟浏览器存储失败。 */ -class TestStorage { - value: string | null - failOnSet = false - - constructor(value: string | null = null) { - this.value = value - } - - getItem() { - return this.value - } - - setItem(_key: string, value: string) { - if (this.failOnSet) throw new Error('storage full') - this.value = value - } -} - -/** 根据 purpose 生成测试快照,避免测试自己重复写一套容易过期的步骤顺序。 */ -function createSteps( - prefix: string, - purpose: WorkflowRunPurpose = 'create_character', - activeIndex = 0, -): WorkflowStep[] { - return WORKFLOW_STEP_ORDERS[purpose].map((type, index) => ({ - id: `${prefix}:${type}`, - type, - status: index === activeIndex ? 'active' : 'locked', - taskId: null, - candidateTaskIds: [], - submissionId: null, - error: null, - referenceStepIds: [], - })) -} -function createRevision( - id = 'revision-1', - purpose: WorkflowRunPurpose = 'create_character', -): WorkflowRevision { - return { - id, - basedOnRevisionId: null, - restartStepId: null, - status: 'active', - steps: createSteps(id, purpose), - generationStatus: 'not_started', - exportStatus: 'not_exported', - createdAt: '2026-08-03T00:00:00.000Z', - } -} - -function createRun(id = 'run-1'): WorkflowRun { +function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { return { id, projectId: 'project-1', + source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, + status: 'active', + steps: [ + { + id: 'step-character', + nodeId: 'character-node', + type: 'character', + status: 'active', + phase: 'generating_character_candidates', + generations: [], + error: null, + }, + { + id: 'step-action', + nodeId: 'action-node', + type: 'action', + status: 'locked', + phase: 'configuring_action', + generations: [], + error: null, + }, + ], + characterInput: { prompt: '像素骑士', referenceMedia: [] }, characterId: null, outfitId: null, - selectedAt: null, - purpose: 'create_character', - driver: 'ai', - status: 'active', - currentRevisionId: 'revision-1', - revisions: [createRevision()], - prompt: 'Create a hero', - createdAt: '2026-08-03T00:00:00.000Z', - updatedAt: '2026-08-03T00:00:00.000Z', - } -} - -function createAddActionRun(): WorkflowRun { - const base = createRun('run-add-action') - return { - id: base.id, - projectId: base.projectId, - purpose: 'add_action', - characterId: 'character-1', - outfitId: 'outfit-1', - actionId: 'action-1', - actionName: '向前行走', - actionType: 'walk', - fps: 12, - driver: base.driver, - status: base.status, - currentRevisionId: base.currentRevisionId, - revisions: [createRevision('revision-1', 'add_action')], - prompt: 'Walk forward', - createdAt: base.createdAt, - updatedAt: base.updatedAt, + characterSelectedAt: null, + actionInput: null, + createdAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', } } -/** 构造“从已通过步骤重做”的两版本历史,用于校验来源链。 */ -function createRunWithHistory(): WorkflowRun { - const first = createRevision() - first.status = 'abandoned' - first.steps = first.steps.map((step, index) => ({ - ...step, - status: index === 0 ? 'passed' : 'locked', - })) - const second = createRevision('revision-2') - second.basedOnRevisionId = first.id - second.restartStepId = first.steps[0]!.id - second.steps[0]!.referenceStepIds = [first.steps[0]!.id] - +function createMemoryStorage(initial: string | null = null) { + let value = initial return { - ...createRun(), - currentRevisionId: second.id, - revisions: [first, second], + getItem: () => value, + setItem: (_key: string, next: string) => { + value = next + }, + read: () => value, } } -describe('createWorkflowRunStore', () => { - // 创建契约:同一界面可连续完成两任务,但底层必须创建两种不同步骤模板的 Run。 - it('creates a character task with only the character steps', () => { - const ids = ['run-1', 'revision-1', 'step-1', 'step-2', 'step-3'] - const store = createWorkflowRunStore({ - storage: null, - createId: () => ids.shift()!, - now: () => '2026-08-03T01:00:00.000Z', - }) +describe('WorkflowRunRepository', () => { + it('uses an asynchronous CRUD contract without change subscriptions', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + expect('subscribe' in repository).toBe(false) + expect('subscribeAll' in repository).toBe(false) + + const createdPromise = repository.create(createSnapshot()) + expect(createdPromise).toBeInstanceOf(Promise) + await createdPromise + await expect(repository.get('run-1')).resolves.toMatchObject({ id: 'run-1' }) + await expect(repository.list('project-1')).resolves.toHaveLength(1) + await expect(repository.save(createSnapshot())).resolves.toMatchObject({ id: 'run-1' }) + }) - const run = store.create({ - projectId: 'project-1', - purpose: 'create_character', - driver: 'ai', - prompt: ' Create a hero ', - }) + it('persists a versioned snapshot and hydrates it in a new repository', async () => { + const storage = createMemoryStorage() + const repository = createWorkflowRunRepository({ storage }) + await repository.create(createSnapshot()) - expect(CHARACTER_CANDIDATE_COUNT).toBe(4) - expect(run).toMatchObject({ - id: 'run-1', - purpose: 'create_character', - characterId: null, - outfitId: null, - selectedAt: null, - prompt: 'Create a hero', - createdAt: '2026-08-03T01:00:00.000Z', - updatedAt: '2026-08-03T01:00:00.000Z', + expect(JSON.parse(storage.read()!)).toMatchObject({ + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [{ id: 'run-1' }], }) - expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( - WORKFLOW_STEP_ORDERS.create_character, - ) - expect(run.revisions[0]?.steps.map((step) => step.status)).toEqual([ - 'active', - 'locked', - 'locked', - ]) + const restored = createWorkflowRunRepository({ storage }) + await expect(restored.get('run-1')).resolves.toEqual(createSnapshot()) }) - it('creates an action task only from an existing character and outfit', () => { - const ids = [ - 'run-2', - 'revision-2', - 'step-1', - 'step-2', - 'step-3', - 'step-4', - 'step-5', - 'step-6', - 'action-1', - ] - const store = createWorkflowRunStore({ - storage: null, - createId: () => ids.shift()!, - now: () => '2026-08-03T02:00:00.000Z', - }) - - const run = store.create({ - projectId: 'project-1', - purpose: 'add_action', - driver: 'manual', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '向前行走', - actionType: 'walk', - fps: 12, - prompt: 'Walk forward', - }) + it('returns clones so callers cannot mutate persisted state without save', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + await repository.create(createSnapshot()) + const loaded = (await repository.get('run-1'))! + loaded.status = 'interrupted' - expect(run).toMatchObject({ - purpose: 'add_action', - characterId: 'character-1', - outfitId: 'outfit-1', - actionId: 'action-1', - actionName: '向前行走', - actionType: 'walk', - fps: 12, - }) - expect(run.revisions[0]?.steps.map((step) => step.type)).toEqual( - WORKFLOW_STEP_ORDERS.add_action, - ) + await expect(repository.get('run-1')).resolves.toMatchObject({ status: 'active' }) }) - // 快照所有权契约:保存后修改原对象或查询结果,都不能绕过 Store 改写内存。 - it('persists versioned snapshots and returns defensive copies', () => { - const storage = new TestStorage() - const store = createWorkflowRunStore({ storage }) - const run = createRun() - - store.save(run) - run.prompt = 'changed outside' - const restored = store.get(run.id)! - restored.revisions[0]!.steps[0]!.status = 'failed' + it('rejects duplicate creation and structurally invalid card phases', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + await repository.create(createSnapshot()) + await expect(repository.create(createSnapshot())).rejects.toThrow('已存在') - expect(store.get(run.id)?.prompt).toBe('Create a hero') - expect(store.get(run.id)?.revisions[0]?.steps[0]?.status).toBe('active') - expect(JSON.parse(storage.value!)).toEqual({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [createRun()], - }) + const invalid = createSnapshot('run-invalid') + invalid.steps[0]!.phase = 'reviewing_animation' + expect(isWorkflowRunSnapshot(invalid)).toBe(false) + await expect(repository.save(invalid)).rejects.toThrow('Invalid WorkflowRun snapshot') }) - it('hydrates a valid revision history and exposes it through list', () => { - const run = createRunWithHistory() - const storage = new TestStorage( - JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [run] }), + it('ignores old or malformed local data instead of hydrating a partial run', async () => { + const storage = createMemoryStorage( + JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION - 1, runs: [createSnapshot()] }), ) - const store = createWorkflowRunStore({ storage }) + const repository = createWorkflowRunRepository({ storage }) + await expect(repository.list()).resolves.toEqual([]) - expect(store.get(run.id)).toEqual(run) - expect(store.list()).toEqual([run]) - }) - - // 恢复边界采用严格白名单:坏 JSON、未知版本和断裂历史链都不得进入内存。 - it.each([ - ['invalid JSON', '{'], - ['unknown version', JSON.stringify({ version: 99, runs: [createRun()] })], - [ - 'missing history source', - JSON.stringify({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [ - { - ...createRunWithHistory(), - revisions: [ - createRunWithHistory().revisions[0], - { ...createRunWithHistory().revisions[1], basedOnRevisionId: 'missing' }, - ], - }, - ], - }), - ], - [ - 'unknown referenced step', + const malformed = createMemoryStorage( JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [ - { - ...createRunWithHistory(), - revisions: [ - createRunWithHistory().revisions[0], - { - ...createRunWithHistory().revisions[1], - steps: createRunWithHistory().revisions[1]!.steps.map((step, index) => - index === 0 ? { ...step, referenceStepIds: ['missing-step'] } : step, - ), - }, - ], - }, - ], + runs: [{ id: 'partial-run', projectId: 'project-1' }], }), - ], - ])('ignores %s during hydration', (_label, serialized) => { - expect(createWorkflowRunStore({ storage: new TestStorage(serialized) }).list()).toEqual([]) - }) - - it('rejects invalid snapshots before they reach memory', () => { - const store = createWorkflowRunStore({ storage: null }) - const invalid = createRun() - invalid.revisions[0]!.steps[0]!.error = 'failed without failed status' - - expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') - expect(store.get(invalid.id)).toBeNull() - }) - - // 动作不是游离资产;它必须同时定位角色和具体造型。 - it('requires character and outfit references when adding an action', () => { - const valid = createAddActionRun() - const store = createWorkflowRunStore({ storage: null }) - - store.save(valid) - expect(store.get(valid.id)).toEqual(valid) - - const invalid = { - ...valid, - characterId: null, - outfitId: null, - } as unknown as WorkflowRun - expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') - - const hydrated = createWorkflowRunStore({ - storage: new TestStorage( - JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), - ), - }) - expect(hydrated.get(invalid.id)).toBeNull() - }) - - // 用户点选候选并不等于任务完成;必须等正式资产保存成功后再原子性填入三个字段。 - it('requires a saved asset and selection time before completing character creation', () => { - const valid = createRun() - valid.status = 'completed' - valid.characterId = 'character-1' - valid.outfitId = 'outfit-1' - valid.selectedAt = '2026-08-03T03:00:00.000Z' - valid.revisions[0]!.status = 'completed' - valid.revisions[0]!.steps = valid.revisions[0]!.steps.map((step) => ({ - ...step, - status: 'passed', - })) - const store = createWorkflowRunStore({ storage: null }) - - store.save(valid) - expect(store.get(valid.id)).toEqual(valid) - - const missingSelection = { ...valid, selectedAt: null } as unknown as WorkflowRun - expect(() => store.save(missingSelection)).toThrow('Invalid WorkflowRun snapshot') - }) - - it('rejects completed snapshots that still contain a failed step', () => { - const invalid = createRun() - invalid.status = 'completed' - invalid.characterId = 'character-1' - invalid.outfitId = 'outfit-1' - invalid.selectedAt = '2026-08-03T03:00:00.000Z' - invalid.revisions[0]!.status = 'completed' - invalid.revisions[0]!.steps = invalid.revisions[0]!.steps.map((step) => ({ - ...step, - status: 'passed', - })) - invalid.revisions[0]!.steps[0]!.status = 'failed' - invalid.revisions[0]!.steps[0]!.error = '生成失败却被标记为完成' - - const store = createWorkflowRunStore({ storage: null }) - expect(() => store.save(invalid)).toThrow('Invalid WorkflowRun snapshot') - - const hydrated = createWorkflowRunStore({ - storage: new TestStorage( - JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION, runs: [invalid] }), - ), - }) - expect(hydrated.get(invalid.id)).toBeNull() - }) - - // taskId 是追踪线索,不是仅在加载中存活的 UI 状态。 - it('retains a generation task id after its step passes', () => { - const run = createRun() - run.revisions[0]!.steps[0] = { - ...run.revisions[0]!.steps[0]!, - status: 'passed', - taskId: 'generation-1', - } - run.revisions[0]!.steps[1] = { - ...run.revisions[0]!.steps[1]!, - status: 'active', - } - const store = createWorkflowRunStore({ storage: null }) - - store.save(run) - - expect(store.get(run.id)?.revisions[0]?.steps[0]?.taskId).toBe('generation-1') - }) - - it('requires exactly four task ids before the first-frame batch can pass', () => { - const run = createAddActionRun() - const steps = run.revisions[0]!.steps - steps[0]!.status = 'passed' - steps[1]!.status = 'passed' - steps[1]!.candidateTaskIds = ['first-1', 'first-2', 'first-3'] - steps[2]!.status = 'active' - const store = createWorkflowRunStore({ storage: null }) - - expect(() => store.save(run)).toThrow('Invalid WorkflowRun snapshot') - - steps[1]!.candidateTaskIds.push('first-4') - store.save(run) - expect(store.get(run.id)?.revisions[0]?.steps[1]?.candidateTaskIds).toHaveLength(4) - }) - - // 四张候选属于临时缓存;运行历史只记录生成 taskId 和最终正式资产引用。 - it('rejects temporary candidate payloads in persisted workflow steps', () => { - const run = createRun() - const withCandidates = { - ...run, - revisions: [ - { - ...run.revisions[0], - steps: run.revisions[0]!.steps.map((step, index) => - index === 1 - ? { - ...step, - output: { - candidates: ['temporary-1', 'temporary-2', 'temporary-3', 'temporary-4'], - }, - } - : step, - ), - }, - ], - } as unknown as WorkflowRun - const store = createWorkflowRunStore({ storage: null }) - - expect(() => store.save(withCandidates)).toThrow('Invalid WorkflowRun snapshot') - }) - - // localStorage 失败不应让当前会话已完成的操作倒退,但刷新恢复能力会降级。 - it('keeps memory authoritative when persistence fails', () => { - const storage = new TestStorage() - storage.failOnSet = true - const store = createWorkflowRunStore({ storage }) - - expect(() => store.save(createRun())).not.toThrow() - expect(store.get('run-1')).toEqual(createRun()) - }) - - it('notifies run and history subscribers without sharing mutable values', () => { - const store = createWorkflowRunStore({ storage: null }) - const runListener = vi.fn((run: WorkflowRun) => { - run.prompt = 'listener mutation' - }) - const listListener = vi.fn() - const unsubscribeRun = store.subscribe('run-1', runListener) - const unsubscribeAll = store.subscribeAll(listListener) - - store.save(createRun()) - - expect(store.get('run-1')?.prompt).toBe('Create a hero') - expect(listListener).toHaveBeenCalledWith([createRun()]) - unsubscribeRun() - unsubscribeAll() - store.save({ ...createRun(), prompt: 'second save' }) - expect(runListener).toHaveBeenCalledTimes(1) - expect(listListener).toHaveBeenCalledTimes(1) - }) - - it('uses the stable browser storage key', () => { - const setItem = vi.fn() - const store = createWorkflowRunStore({ storage: { getItem: () => null, setItem } }) - - store.save(createRun()) - - expect(setItem).toHaveBeenCalledWith(WORKFLOW_RUN_STORAGE_KEY, expect.any(String)) + ) + const malformedRepository = createWorkflowRunRepository({ storage: malformed }) + await expect(malformedRepository.list()).resolves.toEqual([]) }) }) diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index 9d38b566..95a7691c 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -1,323 +1,259 @@ -/** - * WorkflowRun 的本地仓库与运行时边界校验。 - * - * 这个 Store 只做四件事:创建合法初始快照、保存/读取快照、刷新恢复、通知订阅者。 - * 它不是 WorkflowController:不调 Generation API、不处理 SSE、不决定何时进入下一步, - * 也不负责调用后端候选图清理接口。这些编排行为由同一 Entity 下的 - * WorkflowRun Service 组合已有 Generation/Character 端口完成。 - * - * localStorage 是当前没有 WorkflowRun 后端持久化时的刷新恢复适配器, - * 不代表把浏览器宣布为最终服务器数据源。 - */ +/** WorkflowRun 的异步持久化边界与当前 localStorage 适配器。 */ -import type { - CreateWorkflowRunInput, - WorkflowRevision, - WorkflowRun, - WorkflowRunPurpose, -} from '../model' +import type { WorkflowGenerationRef, WorkflowRunSnapshot, WorkflowStep } from '../model' import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, - EXPORT_STATUSES, - GENERATION_STATUSES, - WORKFLOW_DRIVERS, - WORKFLOW_PURPOSES, - WORKFLOW_REVISION_STATUSES, + WORKFLOW_GENERATION_ROLES, + WORKFLOW_RUN_KINDS, WORKFLOW_RUN_STATUSES, WORKFLOW_STEP_ORDERS, + WORKFLOW_STEP_PHASES, WORKFLOW_STEP_STATUSES, + WORKFLOW_STEP_TYPES, } from '../model/constants' -/** 稳定 key 保证刷新前后读取同一份数据,不随页面路由或组件名改动。 */ export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' +export const WORKFLOW_RUN_STORAGE_VERSION = 4 -/** - * 持久化数据版本。当快照结构或业务不变式变更时递增, - * 防止新代码将旧 JSON 误认为合法运行状态。 - */ -export const WORKFLOW_RUN_STORAGE_VERSION = 3 - -type WorkflowRunListener = (run: WorkflowRun) => void -type WorkflowRunListListener = (runs: WorkflowRun[]) => void +const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const -/** 只依赖最小存储能力,测试可用内存替身,未来也可换成其他适配器。 */ interface WorkflowRunStorage { getItem(key: string): string | null setItem(key: string, value: string): void } /** - * WorkflowRun 的最小仓库接口。 - * - * create 只产生初始合法快照;save 保存已由业务层推进的整体快照。 - * subscribe 服务单个创作页,subscribeAll 服务历史列表;两者都不改写数据。 + * 所有方法从一开始就是异步的,后续替换为 HTTP Repository 时调用方式不变。 + * 不提供 subscribe:Run 变化由发起操作的前端逻辑直接获知;后端任务进度由 Generation SSE 负责。 */ -export interface WorkflowRunStore { - create(input: CreateWorkflowRunInput): WorkflowRun - get(runId: WorkflowRun['id']): WorkflowRun | null - list(): WorkflowRun[] - save(run: WorkflowRun): void - subscribe(runId: WorkflowRun['id'], listener: WorkflowRunListener): () => void - subscribeAll(listener: WorkflowRunListListener): () => void +export interface WorkflowRunRepository { + create(run: WorkflowRunSnapshot): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + list(projectId?: string): Promise + save(run: WorkflowRunSnapshot): Promise } -export interface CreateWorkflowRunStoreOptions { - /** null 表示仅保存在当前内存;未传时浏览器默认使用 localStorage。 */ +export interface CreateWorkflowRunRepositoryOptions { storage?: WorkflowRunStorage | null - /** 测试可注入确定性 ID;生产默认使用 crypto.randomUUID。 */ - createId?: () => string - /** 测试可注入确定性时间。 */ - now?: () => string } interface PersistedWorkflowRuns { version: typeof WORKFLOW_RUN_STORAGE_VERSION - runs: WorkflowRun[] + runs: WorkflowRunSnapshot[] } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function isNullableString(value: unknown): value is string | null { - return typeof value === 'string' || value === null -} - function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 + return typeof value === 'string' && value.trim().length > 0 } -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === 'string') +function isNullableString(value: unknown): value is string | null { + return typeof value === 'string' || value === null } function isMember(value: unknown, members: readonly T[]): value is T { return typeof value === 'string' && members.includes(value as T) } -/** - * 校验单个步骤的关键不变式。 - * - * - failed 必须有可读错误,非 failed 不得残留旧错误; - * - submissionId 只能出现在 active 且与 taskId 互斥; - * - taskId 可在 passed/failed 后保留,便于追踪后端任务; - * - 只有 first-frame 可保存最多 4 个 candidateTaskIds,passed 时必须已集齐 4 个; - * - 拒绝 input/output 是为了防止四张临时候选或页面对象被塞进长期快照。 - */ -function isWorkflowStep(value: unknown, expectedType: string): boolean { - if (!isRecord(value)) return false - const candidateTaskIds = isStringArray(value.candidateTaskIds) ? value.candidateTaskIds : null - - const errorIsValid = - isNullableString(value.error) && - (value.status === 'failed' - ? typeof value.error === 'string' && value.error.trim().length > 0 - : value.error === null) - const taskStateIsValid = - isNullableString(value.taskId) && - candidateTaskIds !== null && - new Set(candidateTaskIds).size === candidateTaskIds.length && - candidateTaskIds.every((id) => id.length > 0) && - isNullableString(value.submissionId) && - !(value.taskId !== null && value.submissionId !== null) && - (value.submissionId === null || value.status === 'active') && - (value.taskId === null || ['active', 'passed', 'failed'].includes(String(value.status))) - const candidateTasksAreValid = - candidateTaskIds !== null && - (expectedType === 'first-frame' - ? value.taskId === null && - candidateTaskIds.length <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && - (value.status !== 'passed' || - candidateTaskIds.length === ACTION_FIRST_FRAME_CANDIDATE_COUNT) - : candidateTaskIds.length === 0) - +function isGenerationRef(value: unknown): value is WorkflowGenerationRef { return ( - typeof value.id === 'string' && - value.id.length > 0 && - value.type === expectedType && - isMember(value.status, WORKFLOW_STEP_STATUSES) && - !('input' in value) && - !('output' in value) && - taskStateIsValid && - candidateTasksAreValid && - errorIsValid && - isStringArray(value.referenceStepIds) + isRecord(value) && + isNonEmptyString(value.taskId) && + isMember(value.role, WORKFLOW_GENERATION_ROLES) ) } -/** - * Revision 必须完整包含当前 purpose 的步骤模板,数量、顺序和 type 都要一致。 - * 这会阻止 add_action 在恢复时被误塞入角色母版步骤,也阻止页面自行改变顺序。 - */ -function isWorkflowRevision( - value: unknown, - purpose: WorkflowRunPurpose, -): value is WorkflowRevision { - if (!isRecord(value) || !Array.isArray(value.steps)) return false - const stepIds = value.steps.map((step) => (isRecord(step) ? step.id : null)) - const expectedOrder = WORKFLOW_STEP_ORDERS[purpose] - - return ( - typeof value.id === 'string' && - value.id.length > 0 && - isNullableString(value.basedOnRevisionId) && - isNullableString(value.restartStepId) && - isMember(value.status, WORKFLOW_REVISION_STATUSES) && - value.steps.length === expectedOrder.length && - value.steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) && - new Set(stepIds).size === stepIds.length && - isMember(value.generationStatus, GENERATION_STATUSES) && - isMember(value.exportStatus, EXPORT_STATUSES) && - typeof value.createdAt === 'string' - ) +function phaseMatchesStep(step: WorkflowStep): boolean { + if (step.status === 'passed') return step.phase === 'completed' + if (step.type === 'character') { + return step.phase === 'generating_character_candidates' || step.phase === 'selecting_character' + } + return [ + 'configuring_action', + 'generating_action_candidates', + 'selecting_action_frame', + 'generating_animation', + 'reviewing_animation', + 'exporting_action', + ].includes(step.phase) } -/** - * 验证 Revision 历史链,防止伪造或悬空引用。 - * - * 首版不能有来源;后续版本必须指向更早的 Revision,且只能从该版本中 - * 已 passed 的步骤重开。referenceStepIds 只能引用已经出现的旧步骤, - * 不能指向未来版本或不存在的 ID。 - */ -function hasValidRevisionLine(revisions: WorkflowRevision[]): boolean { - const prior = new Map() - const priorStepIds = new Set() - - for (const [index, revision] of revisions.entries()) { - if (prior.has(revision.id)) return false - if (index === 0) { - if (revision.basedOnRevisionId !== null || revision.restartStepId !== null) return false - } else { - if (revision.basedOnRevisionId === null || revision.restartStepId === null) return false - const source = prior.get(revision.basedOnRevisionId) - if ( - !source?.steps.some( - (step) => step.id === revision.restartStepId && step.status === 'passed', - ) - ) { - return false - } - } - if ( - revision.steps.some((step) => - step.referenceStepIds.some((stepId) => !priorStepIds.has(stepId)), - ) - ) { - return false - } - prior.set(revision.id, revision) - revision.steps.forEach((step) => priorStepIds.add(step.id)) +function hasValidGenerationRefs(step: WorkflowStep): boolean { + const taskIds = step.generations.map((generation) => generation.taskId) + if (new Set(taskIds).size !== taskIds.length) return false + if (step.type === 'character') { + const count = step.generations.length + return ( + step.generations.every((item) => item.role === 'character_candidates') && + count <= 1 && + (step.phase === 'generating_character_candidates' || count === 1) + ) } - return true + const candidateCount = step.generations.filter( + (item) => item.role === 'action_frame_candidate', + ).length + const animationCount = step.generations.filter((item) => item.role === 'animation').length + const rolesAreValid = + step.generations.every((item) => item.role !== 'character_candidates') && + candidateCount <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && + animationCount <= 1 + if (!rolesAreValid) return false + if (step.phase === 'configuring_action') return candidateCount === 0 && animationCount === 0 + if (step.phase === 'generating_action_candidates') return animationCount === 0 + if (step.phase === 'selecting_action_frame') { + return candidateCount === ACTION_FIRST_FRAME_CANDIDATE_COUNT && animationCount === 0 + } + return candidateCount === ACTION_FIRST_FRAME_CANDIDATE_COUNT && animationCount === 1 } -/** - * 整体 Run 校验。它在两个不可信边界调用:读取 localStorage 和 save() 写入前。 - * 因此 TypeScript 类型正确仍不够;JSON、旧版数据和手工断言都可能绕过编译期。 - */ -function isWorkflowRun(value: unknown): value is WorkflowRun { +function isWorkflowStep(value: unknown, expectedType: string): value is WorkflowStep { if ( !isRecord(value) || - !isMember(value.purpose, WORKFLOW_PURPOSES) || - !Array.isArray(value.revisions) || - value.revisions.length === 0 + !isNonEmptyString(value.id) || + !isNonEmptyString(value.nodeId) || + value.type !== expectedType || + !isMember(value.type, WORKFLOW_STEP_TYPES) || + !isMember(value.status, WORKFLOW_STEP_STATUSES) || + !isMember(value.phase, WORKFLOW_STEP_PHASES) || + !Array.isArray(value.generations) || + !value.generations.every(isGenerationRef) || + !isNullableString(value.error) ) { return false } - const purpose = value.purpose - if (!value.revisions.every((revision) => isWorkflowRevision(revision, purpose))) return false - const revisions = value.revisions - const current = revisions.at(-1) - if (!current || current.id !== value.currentRevisionId || !hasValidRevisionLine(revisions)) { + const step = value as unknown as WorkflowStep + const errorIsValid = step.status === 'failed' ? isNonEmptyString(step.error) : step.error === null + return errorIsValid && phaseMatchesStep(step) && hasValidGenerationRefs(step) +} + +function hasValidStepLine(run: WorkflowRunSnapshot): boolean { + const expectedOrder = WORKFLOW_STEP_ORDERS[run.source.key] + if ( + run.steps.length !== expectedOrder.length || + !run.steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) || + new Set(run.steps.map((step) => step.id)).size !== run.steps.length || + new Set(run.steps.map((step) => step.nodeId)).size !== run.steps.length || + run.source.rootNodeId !== run.steps[0]?.nodeId + ) { return false } - // Run 的结果必须与当前 Revision 结果同步,避免页面各读一层时得到矛盾答案。 - const expectedRevisionStatus = - value.status === 'failed' ? 'failed' : value.status === 'completed' ? 'completed' : 'active' - if (current.status !== expectedRevisionStatus) return false - if (revisions.slice(0, -1).some((revision) => revision.status === 'active')) return false + if (run.status === 'completed') return run.steps.every((step) => step.status === 'passed') - // 运行中/已中断保留唯一当前步骤;终态不得继续挂着 active 步骤。 - const activeStepCount = current.steps.filter((step) => step.status === 'active').length + const currentIndex = run.steps.findIndex( + (step) => step.status === 'active' || step.status === 'failed', + ) + if (currentIndex < 0) return false + if (run.status === 'failed' && run.steps[currentIndex]?.status !== 'failed') return false if ( - ((value.status === 'active' || value.status === 'interrupted') && activeStepCount !== 1) || - ((value.status === 'failed' || value.status === 'completed') && activeStepCount !== 0) + (run.status === 'active' || run.status === 'interrupted') && + run.steps[currentIndex]?.status !== 'active' ) { return false } - // completed 表示本次任务的每一步都已经成功,不能夹带 failed/locked 等残留状态。 - if (value.status === 'completed' && current.steps.some((step) => step.status !== 'passed')) { + + return run.steps.every((step, index) => { + if (index < currentIndex) return step.status === 'passed' + if (index === currentIndex) return true + return step.status === 'locked' + }) +} + +function isMediaReference(value: unknown): boolean { + // MediaReference 在 Entity 层是品牌字符串;Repository 只校验可持久化表示。 + return isNonEmptyString(value) +} + +function hasValidInputs(run: WorkflowRunSnapshot): boolean { + const characterInput = run.characterInput as unknown + const characterInputValid = + characterInput === null || + (isRecord(characterInput) && + isNonEmptyString(characterInput.prompt) && + Array.isArray(characterInput.referenceMedia) && + characterInput.referenceMedia.every(isMediaReference)) + if (!characterInputValid) return false + + const characterIsEmpty = + run.characterId === null && run.outfitId === null && run.characterSelectedAt === null + const characterIsSelected = + isNonEmptyString(run.characterId) && + isNonEmptyString(run.outfitId) && + isNonEmptyString(run.characterSelectedAt) + if (!characterIsEmpty && !characterIsSelected) return false + + const actionInput = run.actionInput as unknown + const actionValid = + actionInput === null || + (isRecord(actionInput) && + isNonEmptyString(actionInput.id) && + isNonEmptyString(actionInput.name) && + isMember(actionInput.type, ACTION_TYPES) && + isNullableString(actionInput.prompt) && + Number.isFinite(actionInput.fps) && + typeof actionInput.fps === 'number' && + actionInput.fps > 0) + if (!actionValid) return false + + if (run.source.key === 'character_action') { + if (run.characterInput === null) return false + if (run.actionInput !== null && !characterIsSelected) return false + } else if (run.characterInput !== null || !characterIsSelected || run.actionInput === null) { return false } - // - // 角色 Run 有两个合法阶段:尚未选择时三个字段都为 null, - // 或正式保存成功后 ID 与选择时间同时存在。动作 Run 则从创建起就必须绑定角色造型。 - const targetIsValid = - value.purpose === 'add_action' - ? isNonEmptyString(value.characterId) && - isNonEmptyString(value.outfitId) && - value.selectedAt === undefined && - isNonEmptyString(value.actionId) && - isNonEmptyString(value.actionName) && - ['walk', 'idle', 'attack', 'jump', 'custom'].includes(String(value.actionType)) && - typeof value.fps === 'number' && - Number.isFinite(value.fps) && - value.fps > 0 - : value.purpose === 'create_character' && - ((value.characterId === null && value.outfitId === null && value.selectedAt === null) || - (isNonEmptyString(value.characterId) && - isNonEmptyString(value.outfitId) && - isNonEmptyString(value.selectedAt))) + return run.status !== 'completed' || (characterIsSelected && run.actionInput !== null) +} +export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnapshot { if ( - value.purpose === 'create_character' && - value.status === 'completed' && - value.characterId === null + !isRecord(value) || + !isNonEmptyString(value.id) || + !isNonEmptyString(value.projectId) || + !isRecord(value.source) || + value.source.type !== 'builtin' || + !isMember(value.source.key, WORKFLOW_RUN_KINDS) || + !isNonEmptyString(value.source.rootNodeId) || + !isMember(value.status, WORKFLOW_RUN_STATUSES) || + !Array.isArray(value.steps) || + !isNullableString(value.characterId) || + !isNullableString(value.outfitId) || + !isNullableString(value.characterSelectedAt) || + !isNonEmptyString(value.createdAt) || + !isNonEmptyString(value.updatedAt) ) { return false } - return ( - typeof value.id === 'string' && - value.id.length > 0 && - isNonEmptyString(value.projectId) && - targetIsValid && - isMember(value.driver, WORKFLOW_DRIVERS) && - isMember(value.status, WORKFLOW_RUN_STATUSES) && - isNullableString(value.prompt) && - isNonEmptyString(value.createdAt) && - isNonEmptyString(value.updatedAt) - ) + const run = value as unknown as WorkflowRunSnapshot + return hasValidStepLine(run) && hasValidInputs(run) } -/** - * 持久化读取采用“失败即忽略”策略:一条损坏数据不能阻止应用启动。 - * 这不是默默修复错误;无法证明合法的 Run 不进入内存,避免错误状态被继续推进。 - */ -function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRun[] { +function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRunSnapshot[] { if (storage === null) return [] try { - const serialized = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) - if (serialized === null) return [] - const value: unknown = JSON.parse(serialized) + const raw = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) + if (!raw) return [] + const value: unknown = JSON.parse(raw) if ( !isRecord(value) || value.version !== WORKFLOW_RUN_STORAGE_VERSION || - !Array.isArray(value.runs) + !Array.isArray(value.runs) || + !value.runs.every(isWorkflowRunSnapshot) ) { return [] } - return value.runs.filter(isWorkflowRun).map((run) => structuredClone(run)) + return structuredClone(value.runs) } catch { return [] } } -/** SSR/测试环境没有 window,隐私模式也可能拒绝 localStorage,因此存储能力必须可降级。 */ function resolveBrowserStorage(): WorkflowRunStorage | null { if (typeof window === 'undefined') return null try { @@ -327,143 +263,51 @@ function resolveBrowserStorage(): WorkflowRunStorage | null { } } -/** 运行、版本和步骤都要跨刷新稳定引用,所以不使用数组下标或时间戳充当 ID。 */ -function createRandomId(): string { - if (typeof globalThis.crypto?.randomUUID !== 'function') { - throw new Error('crypto.randomUUID is required to create a WorkflowRun') - } - return globalThis.crypto.randomUUID() -} - -/** - * 创建 WorkflowRun Store。 - * - * 内存快照是当前会话的权威状态,localStorage 仅用于刷新恢复。 - * 所以存储写入失败时不回滚内存:用户当前页面仍可继续工作, - * 但刷新恢复能力已降级。未来接入后端持久化时,应替换适配器而不改变业务模型。 - */ -export function createWorkflowRunStore( - options: CreateWorkflowRunStoreOptions = {}, -): WorkflowRunStore { +/** 当前本地实现;业务层只依赖异步 Repository 接口。 */ +export function createWorkflowRunRepository( + options: CreateWorkflowRunRepositoryOptions = {}, +): WorkflowRunRepository { const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage - const createId = options.createId ?? createRandomId - const now = options.now ?? (() => new Date().toISOString()) const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) - const listeners = new Map>() - const listListeners = new Set() - - const snapshotList = () => [...runs.values()].map((run) => structuredClone(run)) - - const store: WorkflowRunStore = { - create(input) { - // create 只在用户真正发起任务时调用: - // 选好角色后进入动作区域不创建空 Run,点击“生成动作”才创建 add_action。 - const createdAt = now() - const runId = createId() - const revisionId = createId() - // 初始时只激活第一步,后续步骤等待前置条件通过。 - const steps = WORKFLOW_STEP_ORDERS[input.purpose].map((type, index) => ({ - id: createId(), - type, - status: index === 0 ? ('active' as const) : ('locked' as const), - taskId: null, - candidateTaskIds: [], - submissionId: null, - error: null, - referenceStepIds: [], - })) - const base = { - id: runId, - projectId: input.projectId, - purpose: input.purpose, - driver: input.driver, - status: 'active' as const, - currentRevisionId: revisionId, - revisions: [ - { - id: revisionId, - basedOnRevisionId: null, - restartStepId: null, - status: 'active' as const, - steps, - generationStatus: 'not_started' as const, - exportStatus: 'not_exported' as const, - createdAt, - }, - ], - prompt: input.prompt?.trim() || null, - createdAt, - updatedAt: createdAt, - } - const run: WorkflowRun = - input.purpose === 'create_character' - ? { ...base, purpose: input.purpose, characterId: null, outfitId: null, selectedAt: null } - : { - ...base, - purpose: input.purpose, - characterId: input.characterId, - outfitId: input.outfitId, - actionId: createId(), - actionName: input.actionName.trim(), - actionType: input.actionType, - fps: input.fps, - } - - // 统一走 save 以复用运行时校验、持久化和订阅通知,避免 create 产生特例状态。 - store.save(run) - return structuredClone(run) - }, - get(runId) { + + function persist(): void { + const payload: PersistedWorkflowRuns = { + version: WORKFLOW_RUN_STORAGE_VERSION, + runs: [...runs.values()], + } + storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(payload)) + } + + async function write( + run: WorkflowRunSnapshot, + requireNew: boolean, + ): Promise { + if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') + if (requireNew && runs.has(run.id)) throw new Error(`WorkflowRun 已存在:${run.id}`) + const saved = structuredClone(run) + const previous = runs.get(saved.id) + runs.set(saved.id, saved) + try { + persist() + } catch (cause) { + if (previous === undefined) runs.delete(saved.id) + else runs.set(previous.id, previous) + throw new Error('WorkflowRun 本地持久化失败', { cause }) + } + return structuredClone(saved) + } + + return { + create: (run) => write(run, true), + async get(runId) { const run = runs.get(runId) return run ? structuredClone(run) : null }, - list: snapshotList, - save(run) { - if (!isWorkflowRun(run)) throw new TypeError('Invalid WorkflowRun snapshot') - // 内外都使用深拷贝,防止调用方在 save/get 后继续修改对象,绕过校验篡改 Store。 - const saved = structuredClone(run) - runs.set(saved.id, saved) - - const persisted: PersistedWorkflowRuns = { - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [...runs.values()], - } - try { - storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(persisted)) - } catch { - // 持久化失败不撤销已经写入的当前会话状态,只降级刷新恢复能力。 - } - - for (const listener of listeners.get(saved.id) ?? []) { - try { - // 每个订阅者获得独立副本,一个页面不能通过修改参数影响另一个页面。 - listener(structuredClone(saved)) - } catch { - // 一个订阅方失败不能阻断其他订阅方。 - } - } - for (const listener of listListeners) { - try { - listener(snapshotList()) - } catch { - // 历史列表订阅方失败不影响已保存状态。 - } - } - }, - subscribe(runId, listener) { - const runListeners = listeners.get(runId) ?? new Set() - runListeners.add(listener) - listeners.set(runId, runListeners) - return () => { - runListeners.delete(listener) - if (runListeners.size === 0) listeners.delete(runId) - } - }, - subscribeAll(listener) { - listListeners.add(listener) - return () => listListeners.delete(listener) + async list(projectId) { + return [...runs.values()] + .filter((run) => projectId === undefined || run.projectId === projectId) + .map((run) => structuredClone(run)) }, + save: (run) => write(run, false), } - - return store } diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index f8ce8792..cb5616ba 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,67 +1,30 @@ import type { CreateWorkflowRunInput, - WorkflowRevision, WorkflowRun, + WorkflowRunSnapshot, WorkflowStep, } from '@/entities' -/** 更新当前 Revision 中某个步骤的业务数据。 */ +/** 更新某张编辑器卡片的业务输入。 */ export interface UpdateWorkflowStepInput { stepId: WorkflowStep['id'] data: unknown } -/** 从指定 Revision 的指定步骤建立新的执行版本。 */ -export interface RestartWorkflowFromStepInput { - revisionId: WorkflowRevision['id'] - stepId: WorkflowStep['id'] -} - -/** 把某次服务端调用的结果写回目标步骤。 */ +/** 把异步服务端结果交还给发起它的 Run 和卡片。 */ export interface ApplyServerResultInput { - /** 发起请求时所属的 Revision,防止旧的异步结果污染重启后的新版本。 */ - revisionId: WorkflowRevision['id'] + runId: WorkflowRunSnapshot['id'] stepId: WorkflowStep['id'] result: unknown } /** - * Quick Start 与手动工作流共用的流程推进边界,不含界面。 - * 两套界面共享同一套流程:手动模式一次推进一步,Quick Start 连续推进到终点。 - * - * Controller 围绕同一份 WorkflowRun 提供推进、更新、重启和中断。这些操作依赖同一份 - * 步骤数据,不拆成互不共享状态的独立模块。 - * - * 步骤和运行状态由前端管理;服务端只提供生成能力,并持久化最终确认的资产。 + * WorkflowController 后续负责编排 WorkflowDefinition 中的卡片。 + * 运行历史和编辑器版本不通过 Run 内嵌 Revision 表达;重新从根节点执行会创建新 Run。 */ export interface WorkflowController { - /** 初始化一条创建角色或增加动作的流程。 */ create(input: CreateWorkflowRunInput): Promise - - /** 读取当前维护的完整流程快照。 */ - getWorkflow(): WorkflowRun - - /** 按前端规则完成当前步骤并进入下一步;需要服务端时创建对应的 generation。 */ - nextStep(): Promise - - /** 连续推进到终点,Quick Start 使用。 */ - runToCompletion(): Promise - - /** 更新指定步骤的数据;页面不绕过 Controller 直接改流程状态。 */ - updateStep(input: UpdateWorkflowStepInput): Promise - - /** - * 把服务端返回的结果写回目标步骤。 - * 目标 Revision 已被重启取代时丢弃该结果,不写入新的执行线。 - */ - applyServerResult(input: ApplyServerResultInput): Promise - - /** - * 从历史步骤开出新的执行线。 - * 旧 Revision 保留为只读历史,不会被改写成失败或完成。 - */ - restartFromStep(input: RestartWorkflowFromStepInput): Promise - - /** 用户主动停止自动推进;历史保留,不等于失败或完成。 */ - interrupt(): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + applyServerResult(input: ApplyServerResultInput): Promise + updateStep(input: UpdateWorkflowStepInput): Promise } From 014e83a958d3b965b4ec5738e95ae805f9d188de Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:28:45 +0800 Subject: [PATCH 13/27] feat(workflow-run): restore editor revisions --- frontend/src/entities/index.ts | 1 + frontend/src/entities/workflow-run/README.md | 18 +- frontend/src/entities/workflow-run/index.ts | 1 + .../src/entities/workflow-run/model/index.ts | 1 + .../src/entities/workflow-run/model/types.ts | 40 ++-- .../service/workflow-run-service.test.ts | 121 ++++++++++-- .../service/workflow-run-service.ts | 182 ++++++++++++++---- .../store/workflow-run-store.test.ts | 81 +++++--- .../workflow-run/store/workflow-run-store.ts | 138 +++++++++---- .../src/features/workflow-controller/index.ts | 7 +- 10 files changed, 468 insertions(+), 122 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index f67d5580..e825fa1f 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -91,6 +91,7 @@ export type { WorkflowRun, WorkflowRunKind, WorkflowRunRepository, + WorkflowRevision, WorkflowRunService, WorkflowRunSnapshot, WorkflowRunStatus, diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index d299b01e..b4ab2e58 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -7,8 +7,9 @@ WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模 ```text WorkflowDefinition(未来由 Workflow Editor 管理) └─ WorkflowRun(一次执行) - └─ WorkflowStep(与编辑器卡片一一对应) - └─ GenerationTask 引用(可以有 0、1 或多个) + └─ WorkflowRevision(从某张卡片重做形成的执行分支) + └─ WorkflowStep(与编辑器卡片一一对应) + └─ GenerationTask 引用(可以有 0、1 或多个) ``` Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 @@ -21,11 +22,15 @@ Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所 选择一张”;动作卡片内部依次经历“配置、生成四张首帧、选择首帧、生成动画、审核、导出”。 这些内部过程由 `phase` 表达,不拆成额外 Step,因此编辑器无需再写一层合并转换逻辑。 -## 为什么没有 Revision +## Revision 解决什么 -当前产品只需要刷新恢复、失败重试和重新发起任务。失败重试更新当前 Run;整体重做创建 -新 Run。Workflow Editor 的节点编辑属于 WorkflowDefinition 版本,不属于执行快照。 -只有确认需要在同一执行中浏览、切换或回滚多条分支时,才重新评估 Revision。 +Quick Start 默认只有一个初始 Revision。Workflow Editor 从历史卡片重做时,在同一个 Run +中追加新 Revision:目标卡片之前已通过的结果可以复用,目标卡片及其后续内容被重置,旧 +Revision 保持只读。异步请求返回时还要核对发起它的 Revision,避免旧分支的晚到结果污染 +当前分支。 + +WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 WorkflowDefinition 的定义 +版本。用户从新的根节点发起任务时仍创建新 Run,而不是给旧 Run 追加 Revision。 ## 职责 @@ -34,3 +39,4 @@ Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所 - `service` 只提供 `create/get`,返回绑定 Run ID 的实例;运行操作不再重复传 `runId`。 - 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 - Generation SSE 继续由 Generation Entity 负责。 +- 本地快照格式已升到 v5;旧的无 Revision 快照不会被误水合为新结构。 diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 4e79891a..9b61ee7b 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -14,6 +14,7 @@ export type { WorkflowGenerationRef, WorkflowGenerationRole, WorkflowRunKind, + WorkflowRevision, WorkflowRunSnapshot, WorkflowRunStatus, WorkflowStep, diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts index 7bfa4678..c507953a 100644 --- a/frontend/src/entities/workflow-run/model/index.ts +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -14,6 +14,7 @@ export type { WorkflowGenerationRef, WorkflowGenerationRole, WorkflowRunKind, + WorkflowRevision, WorkflowRunSnapshot, WorkflowRunStatus, WorkflowStep, diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index d0a7f02d..3c3e8517 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -43,6 +43,30 @@ export interface WorkflowStep { error: string | null } +/** + * 同一个根任务中的一次执行分支。 + * + * 初始 Revision 没有父级;Workflow Editor 从某张卡片重做时,追加一个指向当前 + * Revision 的新成员。旧 Revision 不再修改,新 Revision 复用目标卡片之前已经通过的 + * 结果,并重置目标卡片及其后续卡片。 + */ +export interface WorkflowRevision { + id: string + parentRevisionId: string | null + /** 初始 Revision 为 null;后续 Revision 指向父 Revision 中触发重做的 Step。 */ + restartedFromStepId: WorkflowStep['id'] | null + steps: WorkflowStep[] + + /** 节点输入和产出属于执行分支,不能放在 Run 顶层覆盖旧 Revision。 */ + characterInput: WorkflowCharacterInput | null + characterId: string | null + outfitId: string | null + characterSelectedAt: string | null + actionInput: WorkflowActionInput | null + + createdAt: string +} + /** 当前 PR 支持的内置流程来源。Workflow Editor 后续会扩展 definition 来源。 */ export interface BuiltinWorkflowRunSource { type: 'builtin' @@ -66,23 +90,17 @@ export interface WorkflowActionInput { /** * 一次根任务的执行数据。 * - * 这里故意没有 driver 和 revision:前者属于界面推进方式,后者如果用于编辑器版本, - * 应由独立的 WorkflowDefinition 表达。重做整个任务时创建新的 Run,而不是在 Run 内 - * 再维护一棵历史树。 + * 这里故意没有 driver:自动或手动推进属于界面行为。Revision 只表达 Workflow Editor + * 在同一个根任务中“从某张卡片重做”的执行分支,不表达 GenerationTask,也不取代 + * WorkflowDefinition 的定义版本。 */ export interface WorkflowRunSnapshot { id: string projectId: string source: BuiltinWorkflowRunSource status: WorkflowRunStatus - steps: WorkflowStep[] - - characterInput: WorkflowCharacterInput | null - characterId: string | null - outfitId: string | null - characterSelectedAt: string | null - - actionInput: WorkflowActionInput | null + currentRevisionId: WorkflowRevision['id'] + revisions: WorkflowRevision[] createdAt: string updatedAt: string diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index 8a98596f..93016d6d 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { Character, CharacterApis } from '../../character' -import type { Generation, GenerationApis, GenerationInput } from '../../generation' +import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' +import type { WorkflowRunSnapshot } from '../model' import { createWorkflowRunRepository } from '../store' import { createWorkflowRunService, @@ -103,6 +104,14 @@ function createFixture() { return { service, repository, generation, characterApis, confirmSelection } } +function currentSteps(snapshot: WorkflowRunSnapshot) { + return currentRevisionSnapshot(snapshot).steps +} + +function currentRevisionSnapshot(snapshot: WorkflowRunSnapshot) { + return snapshot.revisions.find((revision) => revision.id === snapshot.currentRevisionId)! +} + describe('WorkflowRun instance', () => { it('uses one run and two card-aligned steps for character plus first action', async () => { const fixture = createFixture() @@ -112,11 +121,11 @@ describe('WorkflowRun instance', () => { characterPrompt: '一位像素风守夜人', }) const runId = run.id - expect(run.snapshot().steps.map((step) => step.type)).toEqual(['character', 'action']) + expect(currentSteps(run.snapshot()).map((step) => step.type)).toEqual(['character', 'action']) const characters = (await run.start()) as CharacterCandidateBatch expect(characters.candidates).toHaveLength(4) - expect(characters.snapshot.steps[0]).toMatchObject({ + expect(currentSteps(characters.snapshot)[0]).toMatchObject({ type: 'character', status: 'active', phase: 'selecting_character', @@ -124,13 +133,12 @@ describe('WorkflowRun instance', () => { expect(JSON.stringify(await fixture.repository.get(runId))).not.toContain('candidate-1.png') await run.confirmCharacter('candidate-2.png') - expect(run.snapshot()).toMatchObject({ - id: runId, + expect(run.snapshot()).toMatchObject({ id: runId, status: 'active' }) + expect(currentRevisionSnapshot(run.snapshot())).toMatchObject({ characterId: 'character-1', outfitId: 'outfit-1', - status: 'active', }) - expect(run.snapshot().steps[1]).toMatchObject({ + expect(currentSteps(run.snapshot())[1]).toMatchObject({ type: 'action', status: 'active', phase: 'configuring_action', @@ -145,14 +153,16 @@ describe('WorkflowRun instance', () => { const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch expect(firstFrames.candidates).toHaveLength(4) expect(firstFrames.snapshot.id).toBe(runId) - expect(firstFrames.snapshot.steps[1]).toMatchObject({ phase: 'selecting_action_frame' }) + expect(currentSteps(firstFrames.snapshot)[1]).toMatchObject({ + phase: 'selecting_action_frame', + }) await run.confirmActionFirstFrame(firstFrames.candidates[1]!) - expect(run.snapshot().steps[1]).toMatchObject({ phase: 'reviewing_animation' }) + expect(currentSteps(run.snapshot())[1]).toMatchObject({ phase: 'reviewing_animation' }) const published = await run.approveAction() expect(published.snapshot).toMatchObject({ id: runId, status: 'completed' }) - expect(published.snapshot.steps).toEqual( + expect(currentSteps(published.snapshot)).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'character', status: 'passed', phase: 'completed' }), expect.objectContaining({ type: 'action', status: 'passed', phase: 'completed' }), @@ -205,6 +215,95 @@ describe('WorkflowRun instance', () => { expect(run.snapshot().status).toBe('active') }) + it('adds a read-only revision when Workflow Editor restarts from a card', async () => { + const { service } = createFixture() + const run = await service.create({ + kind: 'character_action', + projectId: 'project-1', + characterPrompt: '角色', + }) + const characterCandidates = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characterCandidates.candidates[0]!) + run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) + await run.start() + + const before = run.snapshot() + const parent = before.revisions[0]! + const actionStep = currentSteps(before)[1]! + const restarted = run.restartFromStep(actionStep.id) + const next = restarted.revisions[1]! + + expect(restarted.id).toBe(before.id) + expect(restarted.revisions).toHaveLength(2) + expect(next).toMatchObject({ + parentRevisionId: parent.id, + restartedFromStepId: actionStep.id, + }) + expect(next.steps[0]).toMatchObject({ type: 'character', status: 'passed', phase: 'completed' }) + expect(next.steps[0]?.generations).toEqual(parent.steps[0]?.generations) + expect(next.steps[0]?.id).not.toBe(parent.steps[0]?.id) + expect(next.characterId).toBe(parent.characterId) + expect(next.outfitId).toBe(parent.outfitId) + expect(next.actionInput).toEqual(parent.actionInput) + expect(next.steps[1]).toMatchObject({ + type: 'action', + status: 'active', + phase: 'configuring_action', + generations: [], + }) + expect(restarted.revisions[0]).toEqual(parent) + }) + + it('does not let an old revision asynchronous result mutate the new revision', async () => { + const fixture = createFixture() + const running: Generation<'character_template'> = { + id: 'generation-running', + projectId: 'project-1', + type: 'character_template', + status: 'running', + result: null, + error: null, + } + let emit: (event: GenerationEvent) => void = () => { + throw new Error('生成订阅尚未建立') + } + fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] + fixture.generation.apis.get = vi.fn(async () => running) + fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { + emit = onEvent + return () => undefined + }) + + const run = await fixture.service.create({ + kind: 'character_action', + projectId: 'project-1', + characterPrompt: '异步角色', + }) + const pending = run.start() + await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) + const oldStep = currentSteps(run.snapshot())[0]! + run.restartFromStep(oldStep.id) + emit({ + taskId: running.id, + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [1, 2, 3, 4].map((index) => ({ url: `late-${index}.png` })), + }, + error: null, + }) + + await expect(pending).rejects.toThrow('已切换到新的 Revision') + const snapshot = run.snapshot() + expect(snapshot.revisions).toHaveLength(2) + expect(currentSteps(snapshot)[0]).toMatchObject({ + status: 'active', + phase: 'generating_character_candidates', + generations: [], + }) + }) + it('rejects character and action candidates that do not belong to this run', async () => { const { service, generation, confirmSelection } = createFixture() const run = await service.create({ @@ -248,7 +347,7 @@ describe('WorkflowRun instance', () => { const restored = (await fixture.service.get(run.id))! const resumed = await restored.resumeAction() - expect(resumed.steps[0]).toMatchObject({ phase: 'reviewing_animation' }) + expect(currentSteps(resumed)[0]).toMatchObject({ phase: 'reviewing_animation' }) expect(fixture.generation.create).toHaveBeenCalledTimes(5) }) }) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 2dd7a24b..bb746c75 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -13,6 +13,7 @@ import type { ConfigureWorkflowActionInput, CreateWorkflowRunInput, WorkflowGenerationRole, + WorkflowRevision, WorkflowRunSnapshot, WorkflowStep, WorkflowStepType, @@ -70,6 +71,8 @@ export interface WorkflowRun { save(): Promise interrupt(): WorkflowRunSnapshot continue(): WorkflowRunSnapshot + /** Workflow Editor 从指定卡片重做,并把旧 Revision 保留为只读历史。 */ + restartFromStep(stepId: WorkflowStep['id']): WorkflowRunSnapshot start(): Promise resumeCharacterCandidates(): Promise confirmCharacter(selectedImageUrl: string): Promise @@ -125,9 +128,10 @@ export function createWorkflowRunService( return persist() } - async function fail(message: string): Promise { - if (state.status !== 'active') return + async function fail(message: string, revisionId: string): Promise { + if (state.status !== 'active' || state.currentRevisionId !== revisionId) return mutate((draft) => { + assertCurrentRevision(draft, revisionId) const step = requireActiveStep(draft) step.status = 'failed' step.error = message @@ -138,12 +142,13 @@ export function createWorkflowRunService( async function generateCharacterCandidates(): Promise { assertActive(state) + const revisionId = state.currentRevisionId const step = requireStep(state, 'character') if (step.phase === 'selecting_character') return loadCharacterCandidates() if (step.phase !== 'generating_character_candidates') { throw new Error('角色卡片当前不能生成候选图') } - const input = state.characterInput + const input = currentRevision(state).characterInput if (!input) throw new Error('WorkflowRun 缺少角色生成输入') try { @@ -156,7 +161,9 @@ export function createWorkflowRunService( referenceMedia: input.referenceMedia, }) generationId = generation.id + assertCurrentRevision(state, revisionId) await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) requireStep(draft, 'character').generations.push({ taskId: generation.id, role: 'character_candidates', @@ -169,12 +176,14 @@ export function createWorkflowRunService( ) const result = requireCharacterCandidates(terminal) assertActive(state) + assertCurrentRevision(state, revisionId) await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) requireStep(draft, 'character').phase = 'selecting_character' }) return toCharacterBatch(state, generationId, result) } catch (cause) { - await fail(errorMessage(cause, '角色候选生成失败')) + await fail(errorMessage(cause, '角色候选生成失败'), revisionId) throw asError(cause) } } @@ -191,6 +200,7 @@ export function createWorkflowRunService( async function collectActionCandidates(): Promise { assertActive(state) + const revisionId = state.currentRevisionId const step = requireStep(state, 'action') if (step.phase === 'selecting_action_frame') return loadActionCandidates() if (step.phase !== 'generating_action_candidates') { @@ -199,6 +209,7 @@ export function createWorkflowRunService( const action = requireActionInput(state) const { characterId, outfitId } = requireCharacterBinding(state) const character = await options.characterApis.get(characterId) + assertCurrentRevision(state, revisionId) const outfit = character.outfits.find((item) => item.id === outfitId) if (!outfit?.characterTemplateUrl) throw new Error('动作生成需要已确认的角色母版') @@ -216,7 +227,9 @@ export function createWorkflowRunService( prompt: action.prompt, referenceMedia: [], }) + assertCurrentRevision(state, revisionId) await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) requireStep(draft, 'action').generations.push({ taskId: generation.id, role: 'action_frame_candidate', @@ -226,12 +239,14 @@ export function createWorkflowRunService( const batch = await loadActionCandidates() assertActive(state) + assertCurrentRevision(state, revisionId) await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) requireStep(draft, 'action').phase = 'selecting_action_frame' }) return { ...batch, snapshot: current() } } catch (cause) { - await fail(errorMessage(cause, '动作首帧候选生成失败')) + await fail(errorMessage(cause, '动作首帧候选生成失败'), revisionId) throw asError(cause) } } @@ -270,6 +285,41 @@ export function createWorkflowRunService( draft.status = 'active' }) }, + restartFromStep(stepId) { + const parent = currentRevision(state) + const restartIndex = parent.steps.findIndex((step) => step.id === stepId) + if (restartIndex < 0) throw new Error('重做目标不属于当前 Revision') + if (parent.steps.slice(0, restartIndex).some((step) => step.status !== 'passed')) { + throw new Error('目标卡片之前仍有未通过步骤,不能从这里重做') + } + + const createdAt = now() + const revision: WorkflowRevision = { + id: createId(), + parentRevisionId: parent.id, + restartedFromStepId: stepId, + steps: parent.steps.map((step, index) => + createRestartedStep(step, index, restartIndex, createId), + ), + characterInput: structuredClone(parent.characterInput), + characterId: parent.characterId, + outfitId: parent.outfitId, + characterSelectedAt: parent.characterSelectedAt, + actionInput: structuredClone(parent.actionInput), + createdAt, + } + if (parent.steps[restartIndex]!.type === 'character') { + revision.characterId = null + revision.outfitId = null + revision.characterSelectedAt = null + revision.actionInput = null + } + return mutate((draft) => { + draft.revisions.push(revision) + draft.currentRevisionId = revision.id + draft.status = 'active' + }) + }, async start() { const step = requireActiveStep(state) return step.type === 'character' ? generateCharacterCandidates() : collectActionCandidates() @@ -277,13 +327,15 @@ export function createWorkflowRunService( resumeCharacterCandidates: generateCharacterCandidates, async confirmCharacter(selectedImageUrl) { assertActive(state) + const revisionId = state.currentRevisionId const step = requireStep(state, 'character') if (step.phase !== 'selecting_character') throw new Error('角色尚未进入候选选择阶段') const batch = await loadCharacterCandidates() if (!batch.candidates.includes(selectedImageUrl)) { throw new Error('选中图片不属于当前角色生成任务') } - const characterInput = state.characterInput + assertCurrentRevision(state, revisionId) + const characterInput = currentRevision(state).characterInput if (!characterInput) throw new Error('WorkflowRun 缺少角色生成输入') const confirmed = await options.candidateConfirmationApis.confirmSelection({ projectId: state.projectId, @@ -291,13 +343,16 @@ export function createWorkflowRunService( selectedImageUrl, description: characterInput.prompt, }) + assertCurrentRevision(state, revisionId) return checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) const characterStep = requireStep(draft, 'character') characterStep.status = 'passed' characterStep.phase = 'completed' - draft.characterId = confirmed.character.id - draft.outfitId = confirmed.outfitId - draft.characterSelectedAt = now() + const revision = currentRevision(draft) + revision.characterId = confirmed.character.id + revision.outfitId = confirmed.outfitId + revision.characterSelectedAt = now() const actionStep = requireStep(draft, 'action') actionStep.status = 'active' actionStep.phase = 'configuring_action' @@ -309,7 +364,7 @@ export function createWorkflowRunService( if (step.phase !== 'configuring_action') throw new Error('动作卡片当前不能配置') validateActionInput(input) return mutate((draft) => { - draft.actionInput = { + currentRevision(draft).actionInput = { id: createId(), name: input.actionName.trim(), type: input.actionType, @@ -322,6 +377,7 @@ export function createWorkflowRunService( resumeActionFirstFrameCandidates: collectActionCandidates, async confirmActionFirstFrame(selectedImageUrl) { assertActive(state) + const revisionId = state.currentRevisionId const step = requireStep(state, 'action') if (step.phase !== 'selecting_action_frame') { throw new Error('动作尚未进入首帧选择阶段') @@ -330,6 +386,7 @@ export function createWorkflowRunService( if (!batch.candidates.includes(selectedImageUrl)) { throw new Error('选中图片不属于当前动作首帧任务') } + assertCurrentRevision(state, revisionId) const action = requireActionInput(state) const { characterId, outfitId } = requireCharacterBinding(state) try { @@ -343,19 +400,22 @@ export function createWorkflowRunService( prompt: action.prompt, referenceMedia: [], }) + assertCurrentRevision(state, revisionId) await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) const actionStep = requireStep(draft, 'action') actionStep.generations.push({ taskId: generation.id, role: 'animation' }) actionStep.phase = 'generating_animation' }) return run.resumeAction() } catch (cause) { - await fail(errorMessage(cause, '完整动画生成失败')) + await fail(errorMessage(cause, '完整动画生成失败'), revisionId) throw asError(cause) } }, async resumeAction() { assertActive(state) + const revisionId = state.currentRevisionId const step = requireStep(state, 'action') if (step.phase === 'reviewing_animation') return current() if (step.phase !== 'generating_animation') throw new Error('动作当前不在动画生成阶段') @@ -369,11 +429,13 @@ export function createWorkflowRunService( ), ) assertActive(state) + assertCurrentRevision(state, revisionId) return checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) requireStep(draft, 'action').phase = 'reviewing_animation' }) } catch (cause) { - await fail(errorMessage(cause, '完整动画恢复失败')) + await fail(errorMessage(cause, '完整动画恢复失败'), revisionId) throw asError(cause) } }, @@ -394,7 +456,9 @@ export function createWorkflowRunService( } }, async approveAction() { + const revisionId = state.currentRevisionId const review = await run.getActionReview() + assertCurrentRevision(state, revisionId) const actionInput = requireActionInput(state) const { characterId, outfitId } = requireCharacterBinding(state) const character = await options.characterApis.get(characterId) @@ -428,7 +492,9 @@ export function createWorkflowRunService( : item, ), }) + assertCurrentRevision(state, revisionId) await checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) const actionStep = requireStep(draft, 'action') actionStep.phase = 'completed' actionStep.status = 'passed' @@ -490,30 +556,40 @@ function createInitialSnapshot( }) const steps = input.kind === 'character_action' ? [characterStep(), actionStep(false)] : [actionStep(true)] + const initialRevisionId = createId() return { id: createId(), projectId: input.projectId.trim(), source: { type: 'builtin', key: input.kind, rootNodeId: steps[0]!.nodeId }, status: 'active', - steps, - characterInput: - input.kind === 'character_action' - ? { prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [] } - : null, - characterId: input.kind === 'add_action' ? input.characterId.trim() : null, - outfitId: input.kind === 'add_action' ? input.outfitId.trim() : null, - characterSelectedAt: input.kind === 'add_action' ? createdAt : null, - actionInput: - input.kind === 'add_action' - ? { - id: createId(), - name: input.actionName.trim(), - type: input.actionType, - prompt: input.actionPrompt?.trim() || null, - fps: input.fps, - } - : null, + currentRevisionId: initialRevisionId, + revisions: [ + { + id: initialRevisionId, + parentRevisionId: null, + restartedFromStepId: null, + steps, + characterInput: + input.kind === 'character_action' + ? { prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [] } + : null, + characterId: input.kind === 'add_action' ? input.characterId.trim() : null, + outfitId: input.kind === 'add_action' ? input.outfitId.trim() : null, + characterSelectedAt: input.kind === 'add_action' ? createdAt : null, + actionInput: + input.kind === 'add_action' + ? { + id: createId(), + name: input.actionName.trim(), + type: input.actionType, + prompt: input.actionPrompt?.trim() || null, + fps: input.fps, + } + : null, + createdAt, + }, + ], createdAt, updatedAt: createdAt, } @@ -530,17 +606,47 @@ function validateActionInput( } function requireStep(run: WorkflowRunSnapshot, type: WorkflowStepType): WorkflowStep { - const step = run.steps.find((item) => item.type === type) + const step = currentRevision(run).steps.find((item) => item.type === type) if (!step) throw new Error(`WorkflowRun 缺少 ${type} 卡片`) return step } function requireActiveStep(run: WorkflowRunSnapshot): WorkflowStep { - const step = run.steps.find((item) => item.status === 'active') + const step = currentRevision(run).steps.find((item) => item.status === 'active') if (!step) throw new Error('WorkflowRun 没有当前活动卡片') return step } +function currentRevision(run: WorkflowRunSnapshot): WorkflowRevision { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') + return revision +} + +function assertCurrentRevision(run: WorkflowRunSnapshot, revisionId: string): void { + if (run.currentRevisionId !== revisionId) { + throw new Error('WorkflowRun 已切换到新的 Revision,忽略旧分支的异步结果') + } +} + +function createRestartedStep( + source: WorkflowStep, + index: number, + restartIndex: number, + createId: () => string, +): WorkflowStep { + const step = structuredClone(source) + step.id = createId() + if (index < restartIndex) return step + + step.generations = [] + step.error = null + step.status = index === restartIndex ? 'active' : 'locked' + step.phase = + source.type === 'character' ? 'generating_character_candidates' : 'configuring_action' + return step +} + function assertActive(run: WorkflowRunSnapshot): void { if (run.status !== 'active') throw new Error('WorkflowRun 当前不能继续推进') } @@ -549,13 +655,17 @@ function requireCharacterBinding(run: WorkflowRunSnapshot): { characterId: string outfitId: string } { - if (!run.characterId || !run.outfitId) throw new Error('WorkflowRun 尚未绑定角色和造型') - return { characterId: run.characterId, outfitId: run.outfitId } + const revision = currentRevision(run) + if (!revision.characterId || !revision.outfitId) { + throw new Error('WorkflowRun 尚未绑定角色和造型') + } + return { characterId: revision.characterId, outfitId: revision.outfitId } } function requireActionInput(run: WorkflowRunSnapshot) { - if (!run.actionInput) throw new Error('WorkflowRun 尚未配置动作') - return run.actionInput + const actionInput = currentRevision(run).actionInput + if (!actionInput) throw new Error('WorkflowRun 尚未配置动作') + return actionInput } function generationIdsFor(step: WorkflowStep, role: WorkflowGenerationRole): string[] { diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index 85646eae..fbca64a8 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -13,31 +13,40 @@ function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { projectId: 'project-1', source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, status: 'active', - steps: [ + currentRevisionId: 'revision-1', + revisions: [ { - id: 'step-character', - nodeId: 'character-node', - type: 'character', - status: 'active', - phase: 'generating_character_candidates', - generations: [], - error: null, - }, - { - id: 'step-action', - nodeId: 'action-node', - type: 'action', - status: 'locked', - phase: 'configuring_action', - generations: [], - error: null, + id: 'revision-1', + parentRevisionId: null, + restartedFromStepId: null, + createdAt: '2026-08-05T00:00:00.000Z', + steps: [ + { + id: 'step-character', + nodeId: 'character-node', + type: 'character', + status: 'active', + phase: 'generating_character_candidates', + generations: [], + error: null, + }, + { + id: 'step-action', + nodeId: 'action-node', + type: 'action', + status: 'locked', + phase: 'configuring_action', + generations: [], + error: null, + }, + ], + characterInput: { prompt: '像素骑士', referenceMedia: [] }, + characterId: null, + outfitId: null, + characterSelectedAt: null, + actionInput: null, }, ], - characterInput: { prompt: '像素骑士', referenceMedia: [] }, - characterId: null, - outfitId: null, - characterSelectedAt: null, - actionInput: null, createdAt: '2026-08-05T00:00:00.000Z', updatedAt: '2026-08-05T00:00:00.000Z', } @@ -96,11 +105,37 @@ describe('WorkflowRunRepository', () => { await expect(repository.create(createSnapshot())).rejects.toThrow('已存在') const invalid = createSnapshot('run-invalid') - invalid.steps[0]!.phase = 'reviewing_animation' + invalid.revisions[0]!.steps[0]!.phase = 'reviewing_animation' expect(isWorkflowRunSnapshot(invalid)).toBe(false) await expect(repository.save(invalid)).rejects.toThrow('Invalid WorkflowRun snapshot') }) + it('hydrates revision lineage only when parents and restart steps are valid', () => { + const snapshot = createSnapshot() + const parent = snapshot.revisions[0]! + const revision = { + id: 'revision-2', + parentRevisionId: parent.id, + restartedFromStepId: parent.steps[0]!.id, + createdAt: '2026-08-05T00:01:00.000Z', + steps: parent.steps.map((step, index) => ({ + ...structuredClone(step), + id: `step-v2-${index}`, + })), + characterInput: structuredClone(parent.characterInput), + characterId: parent.characterId, + outfitId: parent.outfitId, + characterSelectedAt: parent.characterSelectedAt, + actionInput: structuredClone(parent.actionInput), + } + snapshot.revisions.push(revision) + snapshot.currentRevisionId = revision.id + expect(isWorkflowRunSnapshot(snapshot)).toBe(true) + + revision.parentRevisionId = 'missing-revision' + expect(isWorkflowRunSnapshot(snapshot)).toBe(false) + }) + it('ignores old or malformed local data instead of hydrating a partial run', async () => { const storage = createMemoryStorage( JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION - 1, runs: [createSnapshot()] }), diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index 95a7691c..3bfbbce1 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -1,6 +1,11 @@ /** WorkflowRun 的异步持久化边界与当前 localStorage 适配器。 */ -import type { WorkflowGenerationRef, WorkflowRunSnapshot, WorkflowStep } from '../model' +import type { + WorkflowGenerationRef, + WorkflowRevision, + WorkflowRunSnapshot, + WorkflowStep, +} from '../model' import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, WORKFLOW_GENERATION_ROLES, @@ -13,7 +18,7 @@ import { } from '../model/constants' export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' -export const WORKFLOW_RUN_STORAGE_VERSION = 4 +export const WORKFLOW_RUN_STORAGE_VERSION = 5 const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const @@ -131,36 +136,93 @@ function isWorkflowStep(value: unknown, expectedType: string): value is Workflow return errorIsValid && phaseMatchesStep(step) && hasValidGenerationRefs(step) } -function hasValidStepLine(run: WorkflowRunSnapshot): boolean { - const expectedOrder = WORKFLOW_STEP_ORDERS[run.source.key] +function hasValidStepLine( + steps: WorkflowStep[], + expectedOrder: readonly string[], + rootNodeId: string, +): boolean { if ( - run.steps.length !== expectedOrder.length || - !run.steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) || - new Set(run.steps.map((step) => step.id)).size !== run.steps.length || - new Set(run.steps.map((step) => step.nodeId)).size !== run.steps.length || - run.source.rootNodeId !== run.steps[0]?.nodeId + steps.length !== expectedOrder.length || + !steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) || + new Set(steps.map((step) => step.id)).size !== steps.length || + new Set(steps.map((step) => step.nodeId)).size !== steps.length || + rootNodeId !== steps[0]?.nodeId ) { return false } - if (run.status === 'completed') return run.steps.every((step) => step.status === 'passed') + if (steps.every((step) => step.status === 'passed')) return true - const currentIndex = run.steps.findIndex( + const currentIndex = steps.findIndex( (step) => step.status === 'active' || step.status === 'failed', ) if (currentIndex < 0) return false - if (run.status === 'failed' && run.steps[currentIndex]?.status !== 'failed') return false + + return steps.every((step, index) => { + if (index < currentIndex) return step.status === 'passed' + if (index === currentIndex) return true + return step.status === 'locked' + }) +} + +function isWorkflowRevision( + value: unknown, + expectedOrder: readonly string[], + rootNodeId: string, + runKind: WorkflowRunSnapshot['source']['key'], +): value is WorkflowRevision { + return ( + isRecord(value) && + isNonEmptyString(value.id) && + isNullableString(value.parentRevisionId) && + isNullableString(value.restartedFromStepId) && + Array.isArray(value.steps) && + hasValidStepLine(value.steps as WorkflowStep[], expectedOrder, rootNodeId) && + hasValidInputs(value as unknown as WorkflowRevision, runKind) && + isNonEmptyString(value.createdAt) + ) +} + +function hasValidRevisions(run: WorkflowRunSnapshot): boolean { + const expectedOrder = WORKFLOW_STEP_ORDERS[run.source.key] + if ( + run.revisions.length === 0 || + !run.revisions.every((revision) => + isWorkflowRevision(revision, expectedOrder, run.source.rootNodeId, run.source.key), + ) || + new Set(run.revisions.map((revision) => revision.id)).size !== run.revisions.length || + new Set(run.revisions.flatMap((revision) => revision.steps.map((step) => step.id))).size !== + run.revisions.length * expectedOrder.length + ) { + return false + } + + const current = run.revisions.find((revision) => revision.id === run.currentRevisionId) + if (!current) return false + const currentStatuses = current.steps.map((step) => step.status) + if (run.status === 'completed' && !currentStatuses.every((status) => status === 'passed')) { + return false + } + if (run.status === 'failed' && !currentStatuses.includes('failed')) return false if ( (run.status === 'active' || run.status === 'interrupted') && - run.steps[currentIndex]?.status !== 'active' + !currentStatuses.includes('active') ) { return false } - return run.steps.every((step, index) => { - if (index < currentIndex) return step.status === 'passed' - if (index === currentIndex) return true - return step.status === 'locked' + return run.revisions.every((revision, index) => { + if (index === 0) { + return revision.parentRevisionId === null && revision.restartedFromStepId === null + } + const parentIndex = run.revisions.findIndex( + (candidate) => candidate.id === revision.parentRevisionId, + ) + if (parentIndex < 0 || parentIndex >= index || revision.restartedFromStepId === null) + return false + return run.revisions[parentIndex]!.steps.some( + (step) => step.id === revision.restartedFromStepId, + ) }) } @@ -169,8 +231,11 @@ function isMediaReference(value: unknown): boolean { return isNonEmptyString(value) } -function hasValidInputs(run: WorkflowRunSnapshot): boolean { - const characterInput = run.characterInput as unknown +function hasValidInputs( + revision: WorkflowRevision, + runKind: WorkflowRunSnapshot['source']['key'], +): boolean { + const characterInput = revision.characterInput as unknown const characterInputValid = characterInput === null || (isRecord(characterInput) && @@ -180,14 +245,16 @@ function hasValidInputs(run: WorkflowRunSnapshot): boolean { if (!characterInputValid) return false const characterIsEmpty = - run.characterId === null && run.outfitId === null && run.characterSelectedAt === null + revision.characterId === null && + revision.outfitId === null && + revision.characterSelectedAt === null const characterIsSelected = - isNonEmptyString(run.characterId) && - isNonEmptyString(run.outfitId) && - isNonEmptyString(run.characterSelectedAt) + isNonEmptyString(revision.characterId) && + isNonEmptyString(revision.outfitId) && + isNonEmptyString(revision.characterSelectedAt) if (!characterIsEmpty && !characterIsSelected) return false - const actionInput = run.actionInput as unknown + const actionInput = revision.actionInput as unknown const actionValid = actionInput === null || (isRecord(actionInput) && @@ -200,14 +267,19 @@ function hasValidInputs(run: WorkflowRunSnapshot): boolean { actionInput.fps > 0) if (!actionValid) return false - if (run.source.key === 'character_action') { - if (run.characterInput === null) return false - if (run.actionInput !== null && !characterIsSelected) return false - } else if (run.characterInput !== null || !characterIsSelected || run.actionInput === null) { + if (runKind === 'character_action') { + if (revision.characterInput === null) return false + if (revision.actionInput !== null && !characterIsSelected) return false + } else if ( + revision.characterInput !== null || + !characterIsSelected || + revision.actionInput === null + ) { return false } - return run.status !== 'completed' || (characterIsSelected && run.actionInput !== null) + const completed = revision.steps.every((step) => step.status === 'passed') + return !completed || (characterIsSelected && revision.actionInput !== null) } export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnapshot { @@ -220,10 +292,8 @@ export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnaps !isMember(value.source.key, WORKFLOW_RUN_KINDS) || !isNonEmptyString(value.source.rootNodeId) || !isMember(value.status, WORKFLOW_RUN_STATUSES) || - !Array.isArray(value.steps) || - !isNullableString(value.characterId) || - !isNullableString(value.outfitId) || - !isNullableString(value.characterSelectedAt) || + !isNonEmptyString(value.currentRevisionId) || + !Array.isArray(value.revisions) || !isNonEmptyString(value.createdAt) || !isNonEmptyString(value.updatedAt) ) { @@ -231,7 +301,7 @@ export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnaps } const run = value as unknown as WorkflowRunSnapshot - return hasValidStepLine(run) && hasValidInputs(run) + return hasValidRevisions(run) } function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRunSnapshot[] { diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index cb5616ba..63dabe71 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,5 +1,6 @@ import type { CreateWorkflowRunInput, + WorkflowRevision, WorkflowRun, WorkflowRunSnapshot, WorkflowStep, @@ -7,6 +8,7 @@ import type { /** 更新某张编辑器卡片的业务输入。 */ export interface UpdateWorkflowStepInput { + revisionId: WorkflowRevision['id'] stepId: WorkflowStep['id'] data: unknown } @@ -14,13 +16,16 @@ export interface UpdateWorkflowStepInput { /** 把异步服务端结果交还给发起它的 Run 和卡片。 */ export interface ApplyServerResultInput { runId: WorkflowRunSnapshot['id'] + /** 旧 Revision 的异步结果不能写入当前新分支。 */ + revisionId: WorkflowRevision['id'] stepId: WorkflowStep['id'] result: unknown } /** * WorkflowController 后续负责编排 WorkflowDefinition 中的卡片。 - * 运行历史和编辑器版本不通过 Run 内嵌 Revision 表达;重新从根节点执行会创建新 Run。 + * 从根节点发起的是新 Run;在同一 Run 中从历史卡片重做会追加 WorkflowRevision。 + * WorkflowDefinition 的定义版本仍是另一层概念,不能拿执行 Revision 代替。 */ export interface WorkflowController { create(input: CreateWorkflowRunInput): Promise From 249de204b7b81e8fa67c4e4a7ed6cce393f2a80a Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:29:38 +0800 Subject: [PATCH 14/27] docs(workflow-run): define backend agent boundary --- frontend/src/entities/workflow-run/README.md | 15 +++++++++++++++ frontend/src/entities/workflow-run/model/types.ts | 3 +++ .../workflow-run/service/workflow-run-service.ts | 4 ++++ frontend/src/pages/home/index.tsx | 7 +++---- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index b4ab2e58..46cca099 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -10,6 +10,7 @@ WorkflowDefinition(未来由 Workflow Editor 管理) └─ WorkflowRevision(从某张卡片重做形成的执行分支) └─ WorkflowStep(与编辑器卡片一一对应) └─ GenerationTask 引用(可以有 0、1 或多个) + └─ 后端 Generation Agent(提示词编译与工具选择) ``` Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 @@ -32,6 +33,19 @@ Revision 保持只读。异步请求返回时还要核对发起它的 Revision WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 WorkflowDefinition 的定义 版本。用户从新的根节点发起任务时仍创建新 Run,而不是给旧 Run 追加 Revision。 +## 与 Generation Agent 的边界 + +Generation Agent 位于后端,不属于 WorkflowRun。WorkflowRun 只保存用户创作意图、当前卡片 +状态以及 `GenerationTask` ID,用这些稳定业务数据完成页面恢复。Agent 读取任务输入后统一编译 +模型提示词,并从白名单中选择图片生成、动作生成、质量检查等工具。 + +`WorkflowCharacterInput.prompt` 和 `WorkflowActionInput.prompt` 虽然沿用 API 中的字段名,表达的 +都是用户原始创作意图,不是可以直接发送给模型供应商的最终提示词。最终提示词、模板版本、 +Agent 版本、工具参数和执行结果由后端 GenerationTask 的执行记录负责,不能写进前端快照。 + +这样更换 Agent、提示词模板或模型供应商时,不需要迁移 WorkflowRun;刷新页面时,前端也只需 +根据 GenerationTask ID 恢复任务,不需要重放 Agent 的内部推理过程。 + ## 职责 - `model` 不依赖页面、localStorage 或 SSE。 @@ -39,4 +53,5 @@ WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 Workf - `service` 只提供 `create/get`,返回绑定 Run ID 的实例;运行操作不再重复传 `runId`。 - 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 - Generation SSE 继续由 Generation Entity 负责。 +- Agent 的提示词编译、工具白名单和调用轨迹由后端 Generation 模块负责。 - 本地快照格式已升到 v5;旧的无 Revision 快照不会被误水合为新结构。 diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index 3c3e8517..18b3f7c8 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -22,6 +22,7 @@ export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] /** * Step 对后端 GenerationTask 的引用。 * 一个角色卡片对应一次四图生成;一个动作卡片可以对应四次首帧生成和一次动画生成。 + * 后端任务内部可以由 Agent 编译提示词并调用工具,但这些实现细节不进入前端快照。 */ export interface WorkflowGenerationRef { taskId: Generation['id'] @@ -75,6 +76,7 @@ export interface BuiltinWorkflowRunSource { } export interface WorkflowCharacterInput { + /** 用户的角色创作意图;后端 Agent 会据此编译模型最终提示词。 */ prompt: string referenceMedia: readonly MediaReference[] } @@ -83,6 +85,7 @@ export interface WorkflowActionInput { id: string name: string type: ActionType + /** 用户的动作描述;不是直接发送给模型供应商的最终提示词。 */ prompt: string | null fps: number } diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index bb746c75..18a7dd44 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -92,6 +92,10 @@ export interface WorkflowRunService { export interface CreateWorkflowRunServiceOptions { repository: WorkflowRunRepository + /** + * 前端只提交用户意图并订阅 GenerationTask;提示词编译和工具调用由后端 Agent 完成。 + * WorkflowRun Service 不读取 Agent 推理过程,也不承担模型供应商调用。 + */ generationApis: GenerationApis characterApis: CharacterApis candidateConfirmationApis: CharacterCandidateConfirmationApis diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index eae909b7..2721ab3e 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -29,10 +29,9 @@ export function HomePage() { {/* - 首屏用的三段式说法,是 entities/workflow-run 那八步 WORKFLOW_STEP_ORDER 的粗粒度概括: - 确认角色 = character-setup + character-template,生成动作 = first-frame + complete-animation, - 检查交付 = review + export;template-candidate 与 action-setup 是流程内部环节,首屏不提。 - 这份对应关系目前只写在这里,八步一变这段文案不会跟着变,改流程时要一并改。 + 首屏三段式文案是 WorkflowRun 两张业务卡片的产品化表达: + “确认角色”对应 character 卡片,“生成动作”和“检查交付”对应 action 卡片内部 phase。 + phase 变化时要同步检查这段用户文案,但首页不展示内部状态名称。 */}
    Date: Thu, 6 Aug 2026 10:33:53 +0800 Subject: [PATCH 15/27] fix(workflow-run): align character asset contracts --- .../service/workflow-run-service.test.ts | 26 ++++++++++++++----- .../service/workflow-run-service.ts | 12 +++++---- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index 93016d6d..e044fd0d 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -15,16 +15,18 @@ function createCharacter(): Character { return { id: 'character-1', projectId: 'project-1', - createdAt: '2026-08-05T00:00:00.000Z', - updatedAt: '2026-08-05T00:00:00.000Z', + name: '守夜人', + description: '一位像素风守夜人', + referenceImageUrl: 'candidate-2.png', + dataVersion: 1, + status: 1, outfits: [ { id: 'outfit-1', characterId: 'character-1', name: '默认造型', - candidateCharacterTemplates: [], - characterTemplateUrl: 'candidate-2.png', - baseFrames: [], + description: null, + previewUrl: 'candidate-2.png', actions: [], }, ], @@ -81,12 +83,18 @@ function createFixture() { let character = createCharacter() const characterApis: CharacterApis = { get: vi.fn(async () => structuredClone(character)), - listByProject: vi.fn(async () => [structuredClone(character)]), + listByProject: vi.fn(async () => ({ + items: [structuredClone(character)], + total: 1, + page: 1, + pageSize: 20, + })), create: vi.fn(async () => structuredClone(character)), update: vi.fn(async (next) => { character = structuredClone(next) return structuredClone(character) }), + remove: vi.fn(async () => undefined), } const confirmSelection = vi.fn(async () => ({ character: structuredClone(character), @@ -171,7 +179,13 @@ describe('WorkflowRun instance', () => { expect(published.character.outfits[0]?.actions[0]).toMatchObject({ name: '向前行走', type: 'walk', + loop: true, fps: 12, + frameCount: 2, + frames: [ + expect.objectContaining({ index: 0, imageUrl: 'frame-1.png' }), + expect.objectContaining({ index: 1, imageUrl: 'frame-2.png' }), + ], }) expect(fixture.generation.create).toHaveBeenCalledTimes(6) expect(fixture.confirmSelection).toHaveBeenCalledTimes(1) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 18a7dd44..8f5acecf 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -215,7 +215,9 @@ export function createWorkflowRunService( const character = await options.characterApis.get(characterId) assertCurrentRevision(state, revisionId) const outfit = character.outfits.find((item) => item.id === outfitId) - if (!outfit?.characterTemplateUrl) throw new Error('动作生成需要已确认的角色母版') + if (!outfit || !character.referenceImageUrl) { + throw new Error('动作生成需要已确认的角色母版') + } try { while ( @@ -472,14 +474,14 @@ export function createWorkflowRunService( id: actionInput.id, outfitId, name: actionInput.name, - kind: 'custom', type: actionInput.type, + loop: ['idle', 'walk', 'run'].includes(actionInput.type), fps: actionInput.fps, - keyFrameIndex: null, - frames: review.frames.map((frame) => ({ + frameCount: review.frames.length, + frames: review.frames.map((frame, index) => ({ + index, imageUrl: frame.imageUrl, durationMs: null, - rootMotion: null, })), } const savedCharacter = await options.characterApis.update({ From dbcea48e62084e0e0f88862cb973d8a962465a79 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:38:00 +0800 Subject: [PATCH 16/27] docs(workflow-run): keep generation implementation neutral --- frontend/src/entities/workflow-run/README.md | 18 ++++++++---------- .../src/entities/workflow-run/model/types.ts | 4 ++-- .../service/workflow-run-service.ts | 4 ++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 46cca099..25741bb0 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -10,7 +10,6 @@ WorkflowDefinition(未来由 Workflow Editor 管理) └─ WorkflowRevision(从某张卡片重做形成的执行分支) └─ WorkflowStep(与编辑器卡片一一对应) └─ GenerationTask 引用(可以有 0、1 或多个) - └─ 后端 Generation Agent(提示词编译与工具选择) ``` Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 @@ -33,18 +32,17 @@ Revision 保持只读。异步请求返回时还要核对发起它的 Revision WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 WorkflowDefinition 的定义 版本。用户从新的根节点发起任务时仍创建新 Run,而不是给旧 Run 追加 Revision。 -## 与 Generation Agent 的边界 +## 与后端生成执行的边界 -Generation Agent 位于后端,不属于 WorkflowRun。WorkflowRun 只保存用户创作意图、当前卡片 -状态以及 `GenerationTask` ID,用这些稳定业务数据完成页面恢复。Agent 读取任务输入后统一编译 -模型提示词,并从白名单中选择图片生成、动作生成、质量检查等工具。 +WorkflowRun 只保存用户创作意图、当前卡片状态以及 `GenerationTask` ID,用这些稳定业务数据 +完成页面恢复。后端如何整理提示词、选择模型和执行生成任务不属于 WorkflowRun 的职责。 `WorkflowCharacterInput.prompt` 和 `WorkflowActionInput.prompt` 虽然沿用 API 中的字段名,表达的 -都是用户原始创作意图,不是可以直接发送给模型供应商的最终提示词。最终提示词、模板版本、 -Agent 版本、工具参数和执行结果由后端 GenerationTask 的执行记录负责,不能写进前端快照。 +都是用户原始创作意图,不等同于模型供应商最终收到的提示词。最终提示词和内部执行过程属于 +后端 GenerationTask 的实现细节,不能写进前端快照。 -这样更换 Agent、提示词模板或模型供应商时,不需要迁移 WorkflowRun;刷新页面时,前端也只需 -根据 GenerationTask ID 恢复任务,不需要重放 Agent 的内部推理过程。 +这样无论后端以后采用固定模板、规则引擎、Agent 或混合方案,都不需要迁移 WorkflowRun;刷新 +页面时,前端只根据 GenerationTask ID 恢复任务。 ## 职责 @@ -53,5 +51,5 @@ Agent 版本、工具参数和执行结果由后端 GenerationTask 的执行记 - `service` 只提供 `create/get`,返回绑定 Run ID 的实例;运行操作不再重复传 `runId`。 - 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 - Generation SSE 继续由 Generation Entity 负责。 -- Agent 的提示词编译、工具白名单和调用轨迹由后端 Generation 模块负责。 +- 提示词整理、模型选择和内部执行过程由后端 Generation 模块负责。 - 本地快照格式已升到 v5;旧的无 Revision 快照不会被误水合为新结构。 diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index 18b3f7c8..4d60dd28 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -22,7 +22,7 @@ export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] /** * Step 对后端 GenerationTask 的引用。 * 一个角色卡片对应一次四图生成;一个动作卡片可以对应四次首帧生成和一次动画生成。 - * 后端任务内部可以由 Agent 编译提示词并调用工具,但这些实现细节不进入前端快照。 + * 后端任务如何整理提示词和执行生成不进入前端快照。 */ export interface WorkflowGenerationRef { taskId: Generation['id'] @@ -76,7 +76,7 @@ export interface BuiltinWorkflowRunSource { } export interface WorkflowCharacterInput { - /** 用户的角色创作意图;后端 Agent 会据此编译模型最终提示词。 */ + /** 用户的角色创作意图;后端生成模块负责转换为实际模型输入。 */ prompt: string referenceMedia: readonly MediaReference[] } diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 8f5acecf..ec6934cb 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -93,8 +93,8 @@ export interface WorkflowRunService { export interface CreateWorkflowRunServiceOptions { repository: WorkflowRunRepository /** - * 前端只提交用户意图并订阅 GenerationTask;提示词编译和工具调用由后端 Agent 完成。 - * WorkflowRun Service 不读取 Agent 推理过程,也不承担模型供应商调用。 + * 前端只提交用户意图并订阅 GenerationTask;实际模型输入和执行方式由后端决定。 + * WorkflowRun Service 不读取后端内部执行过程,也不承担模型供应商调用。 */ generationApis: GenerationApis characterApis: CharacterApis From 5be316dd1b43637553b6c1003d18ae0d32e728f9 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:48:59 +0800 Subject: [PATCH 17/27] docs(workflow-run): keep execution strategy undecided --- frontend/src/entities/workflow-run/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 25741bb0..845f2b84 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -41,8 +41,8 @@ WorkflowRun 只保存用户创作意图、当前卡片状态以及 `GenerationTa 都是用户原始创作意图,不等同于模型供应商最终收到的提示词。最终提示词和内部执行过程属于 后端 GenerationTask 的实现细节,不能写进前端快照。 -这样无论后端以后采用固定模板、规则引擎、Agent 或混合方案,都不需要迁移 WorkflowRun;刷新 -页面时,前端只根据 GenerationTask ID 恢复任务。 +后端以后即使更换提示词整理、模型选择或任务执行方案,也不需要迁移 WorkflowRun;刷新页面时, +前端只根据 GenerationTask ID 恢复任务。 ## 职责 From 6955c4e07bc69ca4f0afc14dd99d2f480b044d5b Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:49:02 +0800 Subject: [PATCH 18/27] docs(workflow-run): keep execution strategy undecided --- frontend/src/entities/workflow-run/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 25741bb0..845f2b84 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -41,8 +41,8 @@ WorkflowRun 只保存用户创作意图、当前卡片状态以及 `GenerationTa 都是用户原始创作意图,不等同于模型供应商最终收到的提示词。最终提示词和内部执行过程属于 后端 GenerationTask 的实现细节,不能写进前端快照。 -这样无论后端以后采用固定模板、规则引擎、Agent 或混合方案,都不需要迁移 WorkflowRun;刷新 -页面时,前端只根据 GenerationTask ID 恢复任务。 +后端以后即使更换提示词整理、模型选择或任务执行方案,也不需要迁移 WorkflowRun;刷新页面时, +前端只根据 GenerationTask ID 恢复任务。 ## 职责 From d47578e8953eee2a311ee4b07598b4791bffe663 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:57:20 +0800 Subject: [PATCH 19/27] fix(workflow-controller): reuse bound workflow runs --- .../features/workflow-controller/README.md | 16 ++++++++- .../workflow-controller/controller.test.ts | 22 ++++++++++++ .../workflow-controller/controller.ts | 35 ++++++++++++++++--- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index c1726ebb..3ff57473 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -17,11 +17,25 @@ Quick Start / Workflow Editor - 把创建角色、追加动作、候选确认、审核通过和节点重做整理成页面命令。 - 根据当前 Revision、活动卡片和 `phase` 返回页面可直接渲染的状态。 - 合并同一个 Run 的并发恢复请求,避免 React StrictMode 或路由重进重复恢复任务。 +- 在一个 Controller 生命周期内复用同一个绑定 Run,使“生成中断”能作用于正在等待结果的实例。 - 在中断、继续、配置动作和节点重做后立即保存 WorkflowRun 检查点。 +## 页面命令 + +| 页面操作 | Controller 命令 | 结果 | +| --- | --- | --- | +| 开始生成角色 | `startCharacter` | 返回四张角色候选 | +| 选定角色 | `confirmCharacter` | 进入动作配置 | +| 给已有角色追加动作 | `startAction` | 返回四张动作首帧候选 | +| 配置首个动作 | `configureAction` | 保存检查点并生成首帧候选 | +| 选定动作首帧 | `confirmActionFirstFrame` | 恢复完整动画并进入审核 | +| 审核通过 | `approveAction` | 将动作写回 Character | +| 页面刷新或重新进入 | `resume` | 恢复到可直接渲染的页面阶段 | + ## 不负责 -- 不保存第二份 WorkflowRun 状态,也不直接访问 Repository 或 localStorage。 +- 不保存第二份 WorkflowRun 快照,也不直接访问 Repository 或 localStorage。实例缓存只用于保证 + 同一页面会话中的命令操作同一个绑定 Run;页面刷新会重新创建 Controller 并从 Service 恢复。 - 不提供 `subscribe` / `subscribeAll`;页面操作本身知道状态何时改变。 - 不解释 GenerationTask 的内部执行方式,不整理最终模型提示词。 - 不直接调用模型、媒体存储或 Character API,这些由 WorkflowRun Entity 组合。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 66ce6a7c..38e9577e 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -208,6 +208,12 @@ describe('WorkflowController', () => { ).resolves.toMatchObject({ candidateTaskIds: ['frame-1', 'frame-2', 'frame-3', 'frame-4'], }) + + await controller.interrupt(characterRun.id) + await expect(controller.getWorkflow(characterRun.id)).resolves.toMatchObject({ + status: 'interrupted', + }) + expect(service.get).not.toHaveBeenCalled() }) it('confirms the character and checkpoints action configuration before generation', async () => { @@ -273,6 +279,22 @@ describe('WorkflowController', () => { ]) }) + it('reuses the bound run when a page command arrives during recovery', async () => { + const run = createRun(createSnapshot('selecting_character')) + const pending = deferred() + vi.mocked(run.resumeCharacterCandidates).mockReturnValue(pending.promise) + const { controller, service } = createFixture([run]) + + const recovery = controller.resume(run.id) + await vi.waitFor(() => expect(run.resumeCharacterCandidates).toHaveBeenCalledOnce()) + await controller.interrupt(run.id) + + expect(service.get).toHaveBeenCalledOnce() + expect(run.interrupt).toHaveBeenCalledOnce() + pending.resolve(characterBatch(run.snapshot())) + await recovery + }) + it('does not submit animation again after the run has advanced', async () => { const run = createRun(createSnapshot('reviewing_animation')) const review = actionReview(run.snapshot()) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index a4aaca10..d7360e2a 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -77,10 +77,35 @@ export interface CreateWorkflowControllerOptions { export function createWorkflowController({ service, }: CreateWorkflowControllerOptions): WorkflowController { + // 一个页面会围绕同一个 Run 连续发出恢复、中断和确认命令。复用绑定实例可以让 + // 中断立即改变正在等待异步结果的那份运行状态,避免旧实例稍后继续推进并覆盖检查点。 + const runs = new Map() + const pendingLoads = new Map>() const pendingResumes = new Map>() + function rememberRun(run: WorkflowRun): WorkflowRun { + runs.set(run.id, run) + return run + } + + async function loadRun(runId: WorkflowRun['id']): Promise { + const loaded = runs.get(runId) + if (loaded) return loaded + + const pending = pendingLoads.get(runId) + if (pending) return pending + + const request = service.get(runId).then((run) => (run ? rememberRun(run) : null)) + pendingLoads.set(runId, request) + const clear = () => { + if (pendingLoads.get(runId) === request) pendingLoads.delete(runId) + } + void request.then(clear, clear) + return request + } + async function requireRun(runId: WorkflowRun['id']): Promise { - const run = await service.get(runId) + const run = await loadRun(runId) if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) return run } @@ -89,7 +114,7 @@ export function createWorkflowController({ const pending = pendingResumes.get(runId) if (pending) return pending - const request = service.get(runId).then((run) => (run ? restoreRun(run) : null)) + const request = loadRun(runId).then((run) => (run ? restoreRun(run) : null)) pendingResumes.set(runId, request) const clear = () => { if (pendingResumes.get(runId) === request) pendingResumes.delete(runId) @@ -117,7 +142,7 @@ export function createWorkflowController({ return { async startCharacter(input) { - const run = await service.create(input) + const run = rememberRun(await service.create(input)) const result = await run.start() if (!isCharacterBatch(result)) throw new Error('角色 Run 没有返回角色候选') return result @@ -128,7 +153,7 @@ export function createWorkflowController({ return { phase: 'action-setup', snapshot } }, async startAction(input) { - const run = await service.create(input) + const run = rememberRun(await service.create(input)) const result = await run.start() if (!isActionBatch(result)) throw new Error('动作 Run 没有返回首帧候选') return result @@ -161,7 +186,7 @@ export function createWorkflowController({ return restoreRun(run) }, async getWorkflow(runId) { - return (await service.get(runId))?.snapshot() ?? null + return (await loadRun(runId))?.snapshot() ?? null }, resume, } From 989947d6c3c09aee90eff0cd6938a8da2aea3d5d Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:26:00 +0800 Subject: [PATCH 20/27] feat(workflow-run): group character actions in one run --- frontend/src/entities/index.ts | 2 + frontend/src/entities/workflow-run/README.md | 26 ++- frontend/src/entities/workflow-run/index.ts | 2 + .../entities/workflow-run/model/constants.ts | 11 +- .../src/entities/workflow-run/model/index.ts | 2 + .../src/entities/workflow-run/model/types.ts | 42 ++-- .../service/workflow-run-service.test.ts | 95 +++++---- .../service/workflow-run-service.ts | 192 +++++++++++------- .../store/workflow-run-store.test.ts | 35 +++- .../workflow-run/store/workflow-run-store.ts | 103 ++++++---- 10 files changed, 318 insertions(+), 192 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 41bc68f6..1c3bbc07 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -73,6 +73,7 @@ export { export type { ActionFirstFrameCandidateBatch, ActionReviewResult, + AppendWorkflowActionInput, BuiltinWorkflowRunSource, ConfigureWorkflowActionInput, CreateWorkflowRunRepositoryOptions, @@ -91,6 +92,7 @@ export type { WorkflowRevision, WorkflowRunService, WorkflowRunSnapshot, + WorkflowRunCharacterBinding, WorkflowRunStatus, WorkflowStep, WorkflowStepPhase, diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index 845f2b84..c1e9c7a1 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -6,21 +6,23 @@ WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模 ```text WorkflowDefinition(未来由 Workflow Editor 管理) - └─ WorkflowRun(一次执行) + └─ WorkflowRun(一张已确认角色图的完整制作记录) └─ WorkflowRevision(从某张卡片重做形成的执行分支) - └─ WorkflowStep(与编辑器卡片一一对应) - └─ GenerationTask 引用(可以有 0、1 或多个) + ├─ Character Step(只出现一次) + ├─ Action Step:待机 + ├─ Action Step:行走 + └─ Action Step:其他新增动作 ``` Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 -`ai/manual driver`。角色创建和首个动作在 Quick Start 中属于一个根任务;用户以后 -单独追加动作时才创建另一个 Run。 +`ai/manual driver`。角色创建、首个动作和以后新增的动作都属于同一个 Run。这里的“同一张 +角色图”由后端确认后返回的 `characterId + outfitId` 标识,不依赖临时候选图 URL。 ## Step 与卡片 -当前内置流程只有 `character` 和 `action` 两种 Step。角色卡片内部依次经历“生成四张候选、 -选择一张”;动作卡片内部依次经历“配置、生成四张首帧、选择首帧、生成动画、审核、导出”。 -这些内部过程由 `phase` 表达,不拆成额外 Step,因此编辑器无需再写一层合并转换逻辑。 +当前内置流程只有 `character` 和 `action` 两种 Step,但 Action Step 可以有多个。角色卡片内部 +依次经历“生成四张候选、选择一张”;每个动作卡片分别经历“配置、生成四张首帧、选择首帧、 +生成动画、审核、导出”。每个动作的输入和 GenerationTask 引用都按 Action Step ID 隔离。 ## Revision 解决什么 @@ -30,7 +32,8 @@ Revision 保持只读。异步请求返回时还要核对发起它的 Revision 当前分支。 WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 WorkflowDefinition 的定义 -版本。用户从新的根节点发起任务时仍创建新 Run,而不是给旧 Run 追加 Revision。 +版本。只有生成一张新的角色图才创建新 Run;给现有角色图新增动作,是在当前 Revision 追加 +Action Step,不创建 Run,也不拿 Revision 冒充动作列表。 ## 与后端生成执行的边界 @@ -48,8 +51,9 @@ WorkflowRun 只保存用户创作意图、当前卡片状态以及 `GenerationTa - `model` 不依赖页面、localStorage 或 SSE。 - `store` 只负责异步持久化,不提供页面订阅。 -- `service` 只提供 `create/get`,返回绑定 Run ID 的实例;运行操作不再重复传 `runId`。 +- `service.create` 只创建角色 Run;`appendAction` 根据 `projectId + characterId + outfitId` 找到 + 原 Run 并追加动作,找不到时明确失败,绝不偷偷创建第二个 Run。 - 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 - Generation SSE 继续由 Generation Entity 负责。 - 提示词整理、模型选择和内部执行过程由后端 Generation 模块负责。 -- 本地快照格式已升到 v5;旧的无 Revision 快照不会被误水合为新结构。 +- 本地快照格式已升到 v6;旧的单动作快照不会被误水合为多动作结构。 diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 9b61ee7b..d4170b7d 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -6,6 +6,7 @@ export { WORKFLOW_STEP_ORDERS, } from './model' export type { + AppendWorkflowActionInput, BuiltinWorkflowRunSource, ConfigureWorkflowActionInput, CreateWorkflowRunInput, @@ -16,6 +17,7 @@ export type { WorkflowRunKind, WorkflowRevision, WorkflowRunSnapshot, + WorkflowRunCharacterBinding, WorkflowRunStatus, WorkflowStep, WorkflowStepPhase, diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts index 66549fb1..579d1cb7 100644 --- a/frontend/src/entities/workflow-run/model/constants.ts +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -1,11 +1,7 @@ /** WorkflowRun 使用的稳定业务词汇。 */ -/** - * 一个 Run 对应从一个根节点开始的一次执行。 - * `character_action` 会在同一个 Run 中先完成角色卡片,再完成动作卡片; - * `add_action` 则从已有角色开始,只包含动作卡片。 - */ -export const WORKFLOW_RUN_KINDS = ['character_action', 'add_action'] as const +/** 一个 Run 绑定一张最终确认的角色图,并容纳这个角色图的全部动作。 */ +export const WORKFLOW_RUN_KINDS = ['character_action'] as const export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const @@ -39,8 +35,7 @@ export const WORKFLOW_GENERATION_ROLES = [ export const CHARACTER_CANDIDATE_COUNT = 4 export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 -/** 两个内置流程的卡片顺序;将来编辑器流程由 WorkflowDefinition 提供节点顺序。 */ +/** 内置流程的最小卡片顺序;后续新增动作会继续追加 action 卡片。 */ export const WORKFLOW_STEP_ORDERS = { character_action: ['character', 'action'], - add_action: ['action'], } as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts index c507953a..83184839 100644 --- a/frontend/src/entities/workflow-run/model/index.ts +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -6,6 +6,7 @@ export { WORKFLOW_STEP_ORDERS, } from './constants' export type { + AppendWorkflowActionInput, BuiltinWorkflowRunSource, ConfigureWorkflowActionInput, CreateWorkflowRunInput, @@ -16,6 +17,7 @@ export type { WorkflowRunKind, WorkflowRevision, WorkflowRunSnapshot, + WorkflowRunCharacterBinding, WorkflowRunStatus, WorkflowStep, WorkflowStepPhase, diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index 4d60dd28..8a78e56d 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -63,7 +63,8 @@ export interface WorkflowRevision { characterId: string | null outfitId: string | null characterSelectedAt: string | null - actionInput: WorkflowActionInput | null + /** 每个动作卡片保存自己的输入;key 是当前 Revision 内的 Action Step ID。 */ + actionInputs: Record createdAt: string } @@ -109,33 +110,30 @@ export interface WorkflowRunSnapshot { updatedAt: string } -interface CreateWorkflowRunInputBase { +/** 新建角色任务;新增动作必须追加到这个角色已经存在的 Run。 */ +export interface CreateWorkflowRunInput { projectId: string + characterPrompt: string + referenceMedia?: readonly MediaReference[] } -/** 创建一个根任务,而不是创建一个 GenerationTask。 */ -export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & - ( - | { - kind: 'character_action' - characterPrompt: string - referenceMedia?: readonly MediaReference[] - } - | { - kind: 'add_action' - characterId: string - outfitId: string - actionName: string - actionType: ActionType - actionPrompt?: string | null - fps: number - } - ) - -/** 角色确认后,在同一个 character_action Run 中配置后续动作卡片。 */ +/** 配置当前活动的动作卡片。 */ export interface ConfigureWorkflowActionInput { actionName: string actionType: ActionType actionPrompt?: string | null fps: number } + +/** 按角色图定位原 Run,并在其中追加动作卡片。 */ +export interface AppendWorkflowActionInput extends ConfigureWorkflowActionInput { + projectId: string + characterId: string + outfitId: string +} + +export interface WorkflowRunCharacterBinding { + projectId: string + characterId: string + outfitId: string +} diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index e044fd0d..1f5dd701 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -124,7 +124,6 @@ describe('WorkflowRun instance', () => { it('uses one run and two card-aligned steps for character plus first action', async () => { const fixture = createFixture() const run = await fixture.service.create({ - kind: 'character_action', projectId: 'project-1', characterPrompt: '一位像素风守夜人', }) @@ -191,33 +190,65 @@ describe('WorkflowRun instance', () => { expect(fixture.confirmSelection).toHaveBeenCalledTimes(1) }) - it('binds operations to the run instance and service only creates or restores instances', async () => { + it('binds operations to the run instance and exposes character-scoped action append', async () => { const { service } = createFixture() - expect(Object.keys(service).sort()).toEqual(['create', 'get']) + expect(Object.keys(service).sort()).toEqual(['appendAction', 'create', 'get']) const created = await service.create({ - kind: 'add_action', projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '待机', - actionType: 'idle', - fps: 8, + characterPrompt: '角色', }) const restored = await service.get(created.id) expect(restored?.id).toBe(created.id) expect(restored?.snapshot()).toEqual(created.snapshot()) }) + it('appends every new action for the same character image to one run', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + projectId: 'project-1', + characterPrompt: '角色', + }) + const characterCandidates = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characterCandidates.candidates[0]!) + run.configureAction({ actionName: '待机', actionType: 'idle', fps: 8 }) + const idleFrames = (await run.start()) as ActionFirstFrameCandidateBatch + await run.confirmActionFirstFrame(idleFrames.candidates[0]!) + await run.approveAction() + + const appended = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + + expect(appended.id).toBe(run.id) + expect(currentSteps(appended.snapshot())).toHaveLength(3) + expect(currentSteps(appended.snapshot())[2]).toMatchObject({ + type: 'action', + status: 'active', + phase: 'generating_action_candidates', + }) + expect(await fixture.repository.list('project-1')).toHaveLength(1) + + const walkFrames = (await appended.start()) as ActionFirstFrameCandidateBatch + await appended.confirmActionFirstFrame(walkFrames.candidates[0]!) + const published = await appended.approveAction() + expect(published.snapshot.id).toBe(run.id) + expect(published.snapshot.status).toBe('completed') + expect(published.character.outfits[0]?.actions.map((action) => action.name)).toEqual([ + '待机', + '行走', + ]) + }) + it('keeps ordinary state transitions local until save is explicitly requested', async () => { const { service, repository } = createFixture() const run = await service.create({ - kind: 'add_action', projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '待机', - actionType: 'idle', - fps: 8, + characterPrompt: '角色', }) run.interrupt() @@ -232,7 +263,6 @@ describe('WorkflowRun instance', () => { it('adds a read-only revision when Workflow Editor restarts from a card', async () => { const { service } = createFixture() const run = await service.create({ - kind: 'character_action', projectId: 'project-1', characterPrompt: '角色', }) @@ -258,13 +288,13 @@ describe('WorkflowRun instance', () => { expect(next.steps[0]?.id).not.toBe(parent.steps[0]?.id) expect(next.characterId).toBe(parent.characterId) expect(next.outfitId).toBe(parent.outfitId) - expect(next.actionInput).toEqual(parent.actionInput) expect(next.steps[1]).toMatchObject({ type: 'action', status: 'active', phase: 'configuring_action', generations: [], }) + expect(next.actionInputs[next.steps[1]!.id]).toEqual(parent.actionInputs[actionStep.id]) expect(restarted.revisions[0]).toEqual(parent) }) @@ -289,7 +319,6 @@ describe('WorkflowRun instance', () => { }) const run = await fixture.service.create({ - kind: 'character_action', projectId: 'project-1', characterPrompt: '异步角色', }) @@ -321,7 +350,6 @@ describe('WorkflowRun instance', () => { it('rejects character and action candidates that do not belong to this run', async () => { const { service, generation, confirmSelection } = createFixture() const run = await service.create({ - kind: 'character_action', projectId: 'project-1', characterPrompt: '角色', }) @@ -329,17 +357,10 @@ describe('WorkflowRun instance', () => { await expect(run.confirmCharacter('foreign.png')).rejects.toThrow('不属于当前角色生成任务') expect(confirmSelection).not.toHaveBeenCalled() - const actionRun = await service.create({ - kind: 'add_action', - projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - await actionRun.start() - await expect(actionRun.confirmActionFirstFrame('foreign.png')).rejects.toThrow( + await run.confirmCharacter('candidate-1.png') + run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) + await run.start() + await expect(run.confirmActionFirstFrame('foreign.png')).rejects.toThrow( '不属于当前动作首帧任务', ) expect(generation.create).toHaveBeenCalledTimes(5) @@ -348,20 +369,18 @@ describe('WorkflowRun instance', () => { it('resumes an action from persisted GenerationTask references', async () => { const fixture = createFixture() const run = await fixture.service.create({ - kind: 'add_action', projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '行走', - actionType: 'walk', - fps: 12, + characterPrompt: '角色', }) + const characters = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characters.candidates[0]!) + run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch await run.confirmActionFirstFrame(firstFrames.candidates[0]!) const restored = (await fixture.service.get(run.id))! const resumed = await restored.resumeAction() - expect(currentSteps(resumed)[0]).toMatchObject({ phase: 'reviewing_animation' }) - expect(fixture.generation.create).toHaveBeenCalledTimes(5) + expect(currentSteps(resumed)[1]).toMatchObject({ phase: 'reviewing_animation' }) + expect(fixture.generation.create).toHaveBeenCalledTimes(6) }) }) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index ec6934cb..d38af506 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -10,6 +10,7 @@ import type { } from '../../generation' import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' import type { + AppendWorkflowActionInput, ConfigureWorkflowActionInput, CreateWorkflowRunInput, WorkflowGenerationRole, @@ -77,6 +78,8 @@ export interface WorkflowRun { resumeCharacterCandidates(): Promise confirmCharacter(selectedImageUrl: string): Promise configureAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot + /** 给当前角色图追加动作卡片;不会创建新的 WorkflowRun。 */ + appendAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot resumeActionFirstFrameCandidates(): Promise confirmActionFirstFrame(selectedImageUrl: string): Promise resumeAction(): Promise @@ -88,6 +91,8 @@ export interface WorkflowRun { export interface WorkflowRunService { create(input: CreateWorkflowRunInput): Promise get(runId: WorkflowRunSnapshot['id']): Promise + /** 根据角色图找到原 Run,并追加动作;找不到时失败,绝不偷偷创建第二个 Run。 */ + appendAction(input: AppendWorkflowActionInput): Promise } export interface CreateWorkflowRunServiceOptions { @@ -205,7 +210,7 @@ export function createWorkflowRunService( async function collectActionCandidates(): Promise { assertActive(state) const revisionId = state.currentRevisionId - const step = requireStep(state, 'action') + const step = requireActiveActionStep(state) if (step.phase === 'selecting_action_frame') return loadActionCandidates() if (step.phase !== 'generating_action_candidates') { throw new Error('动作卡片当前不能生成首帧候选') @@ -221,7 +226,7 @@ export function createWorkflowRunService( try { while ( - generationIdsFor(requireStep(state, 'action'), 'action_frame_candidate').length < + generationIdsFor(requireActiveActionStep(state), 'action_frame_candidate').length < ACTION_FIRST_FRAME_CANDIDATE_COUNT ) { const generation = await options.generationApis.create({ @@ -236,7 +241,7 @@ export function createWorkflowRunService( assertCurrentRevision(state, revisionId) await checkpoint((draft) => { assertCurrentRevision(draft, revisionId) - requireStep(draft, 'action').generations.push({ + requireActiveActionStep(draft).generations.push({ taskId: generation.id, role: 'action_frame_candidate', }) @@ -248,7 +253,7 @@ export function createWorkflowRunService( assertCurrentRevision(state, revisionId) await checkpoint((draft) => { assertCurrentRevision(draft, revisionId) - requireStep(draft, 'action').phase = 'selecting_action_frame' + requireActiveActionStep(draft).phase = 'selecting_action_frame' }) return { ...batch, snapshot: current() } } catch (cause) { @@ -258,7 +263,7 @@ export function createWorkflowRunService( } async function loadActionCandidates(): Promise { - const step = requireStep(state, 'action') + const step = requireActiveActionStep(state) const taskIds = generationIdsFor(step, 'action_frame_candidate') if (taskIds.length !== ACTION_FIRST_FRAME_CANDIDATE_COUNT) { throw new Error(`动作首帧必须包含 ${ACTION_FIRST_FRAME_CANDIDATE_COUNT} 个候选任务`) @@ -293,32 +298,44 @@ export function createWorkflowRunService( }, restartFromStep(stepId) { const parent = currentRevision(state) - const restartIndex = parent.steps.findIndex((step) => step.id === stepId) - if (restartIndex < 0) throw new Error('重做目标不属于当前 Revision') - if (parent.steps.slice(0, restartIndex).some((step) => step.status !== 'passed')) { - throw new Error('目标卡片之前仍有未通过步骤,不能从这里重做') + const target = parent.steps.find((step) => step.id === stepId) + if (!target) throw new Error('重做目标不属于当前 Revision') + if ( + target.type === 'action' && + parent.steps.some((step) => step !== target && step.status !== 'passed') + ) { + throw new Error('当前还有其他未完成卡片,不能重做这个动作') } const createdAt = now() + const actionInputs: WorkflowRevision['actionInputs'] = {} + const steps = + target.type === 'character' + ? [createRestartedStep(target, true, createId), createActionStep(createId, false)] + : parent.steps.map((source) => { + const next = createRestartedStep(source, source === target, createId) + if (source.type === 'action') { + const input = parent.actionInputs[source.id] + if (input) actionInputs[next.id] = structuredClone(input) + } + return next + }) const revision: WorkflowRevision = { id: createId(), parentRevisionId: parent.id, restartedFromStepId: stepId, - steps: parent.steps.map((step, index) => - createRestartedStep(step, index, restartIndex, createId), - ), + steps, characterInput: structuredClone(parent.characterInput), characterId: parent.characterId, outfitId: parent.outfitId, characterSelectedAt: parent.characterSelectedAt, - actionInput: structuredClone(parent.actionInput), + actionInputs, createdAt, } - if (parent.steps[restartIndex]!.type === 'character') { + if (target.type === 'character') { revision.characterId = null revision.outfitId = null revision.characterSelectedAt = null - revision.actionInput = null } return mutate((draft) => { draft.revisions.push(revision) @@ -359,32 +376,51 @@ export function createWorkflowRunService( revision.characterId = confirmed.character.id revision.outfitId = confirmed.outfitId revision.characterSelectedAt = now() - const actionStep = requireStep(draft, 'action') + const actionStep = currentRevision(draft).steps.find( + (item) => item.type === 'action' && item.status === 'locked', + ) + if (!actionStep) throw new Error('WorkflowRun 缺少首个动作卡片') actionStep.status = 'active' actionStep.phase = 'configuring_action' }) }, configureAction(input) { assertActive(state) - const step = requireStep(state, 'action') + const step = requireActiveActionStep(state) if (step.phase !== 'configuring_action') throw new Error('动作卡片当前不能配置') validateActionInput(input) return mutate((draft) => { - currentRevision(draft).actionInput = { - id: createId(), + const actionStep = requireActiveActionStep(draft) + const previous = currentRevision(draft).actionInputs[actionStep.id] + currentRevision(draft).actionInputs[actionStep.id] = { + id: previous?.id ?? createId(), name: input.actionName.trim(), type: input.actionType, prompt: input.actionPrompt?.trim() || null, fps: input.fps, } - requireStep(draft, 'action').phase = 'generating_action_candidates' + actionStep.phase = 'generating_action_candidates' + }) + }, + appendAction(input) { + if (state.status !== 'completed') { + throw new Error('只有已完成当前动作的 WorkflowRun 才能追加新动作') + } + requireCharacterBinding(state) + validateActionInput(input) + return mutate((draft) => { + const revision = currentRevision(draft) + const actionStep = createActionStep(createId, true) + revision.steps.push(actionStep) + revision.actionInputs[actionStep.id] = createActionInput(input, createId) + draft.status = 'active' }) }, resumeActionFirstFrameCandidates: collectActionCandidates, async confirmActionFirstFrame(selectedImageUrl) { assertActive(state) const revisionId = state.currentRevisionId - const step = requireStep(state, 'action') + const step = requireActiveActionStep(state) if (step.phase !== 'selecting_action_frame') { throw new Error('动作尚未进入首帧选择阶段') } @@ -409,7 +445,7 @@ export function createWorkflowRunService( assertCurrentRevision(state, revisionId) await checkpoint((draft) => { assertCurrentRevision(draft, revisionId) - const actionStep = requireStep(draft, 'action') + const actionStep = requireActiveActionStep(draft) actionStep.generations.push({ taskId: generation.id, role: 'animation' }) actionStep.phase = 'generating_animation' }) @@ -422,7 +458,7 @@ export function createWorkflowRunService( async resumeAction() { assertActive(state) const revisionId = state.currentRevisionId - const step = requireStep(state, 'action') + const step = requireActiveActionStep(state) if (step.phase === 'reviewing_animation') return current() if (step.phase !== 'generating_animation') throw new Error('动作当前不在动画生成阶段') const taskId = generationIdFor(step, 'animation') @@ -438,7 +474,7 @@ export function createWorkflowRunService( assertCurrentRevision(state, revisionId) return checkpoint((draft) => { assertCurrentRevision(draft, revisionId) - requireStep(draft, 'action').phase = 'reviewing_animation' + requireActiveActionStep(draft).phase = 'reviewing_animation' }) } catch (cause) { await fail(errorMessage(cause, '完整动画恢复失败'), revisionId) @@ -446,7 +482,7 @@ export function createWorkflowRunService( } }, async getActionReview() { - const step = requireStep(state, 'action') + const step = requireActiveActionStep(state) if (state.status !== 'active' || step.phase !== 'reviewing_animation') { throw new Error('动作尚未进入可审核状态') } @@ -501,7 +537,7 @@ export function createWorkflowRunService( assertCurrentRevision(state, revisionId) await checkpoint((draft) => { assertCurrentRevision(draft, revisionId) - const actionStep = requireStep(draft, 'action') + const actionStep = requireActiveActionStep(draft) actionStep.phase = 'completed' actionStep.status = 'passed' draft.status = 'completed' @@ -527,6 +563,16 @@ export function createWorkflowRunService( const state = await options.repository.get(runId) return state ? bind(state) : null }, + async appendAction(input) { + validateCharacterBinding(input) + validateActionInput(input) + const state = await options.repository.findByCharacter(input) + if (!state) throw new Error('该角色图没有可追加动作的 WorkflowRun') + const run = bind(state) + run.appendAction(input) + await run.save() + return run + }, } } @@ -536,10 +582,7 @@ function createInitialSnapshot( now: () => string, ): WorkflowRunSnapshot { if (!input.projectId.trim()) throw new TypeError('projectId 不能为空') - if (input.kind === 'character_action' && !input.characterPrompt.trim()) { - throw new TypeError('角色描述不能为空') - } - if (input.kind === 'add_action') validateActionInput(input) + if (!input.characterPrompt.trim()) throw new TypeError('角色描述不能为空') const createdAt = now() const characterStep = (): WorkflowStep => ({ @@ -551,23 +594,13 @@ function createInitialSnapshot( generations: [], error: null, }) - const actionStep = (active: boolean): WorkflowStep => ({ - id: createId(), - nodeId: 'builtin-action', - type: 'action', - status: active ? 'active' : 'locked', - phase: active ? 'generating_action_candidates' : 'configuring_action', - generations: [], - error: null, - }) - const steps = - input.kind === 'character_action' ? [characterStep(), actionStep(false)] : [actionStep(true)] + const steps = [characterStep(), createActionStep(createId, false)] const initialRevisionId = createId() return { id: createId(), projectId: input.projectId.trim(), - source: { type: 'builtin', key: input.kind, rootNodeId: steps[0]!.nodeId }, + source: { type: 'builtin', key: 'character_action', rootNodeId: steps[0]!.nodeId }, status: 'active', currentRevisionId: initialRevisionId, revisions: [ @@ -576,23 +609,14 @@ function createInitialSnapshot( parentRevisionId: null, restartedFromStepId: null, steps, - characterInput: - input.kind === 'character_action' - ? { prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [] } - : null, - characterId: input.kind === 'add_action' ? input.characterId.trim() : null, - outfitId: input.kind === 'add_action' ? input.outfitId.trim() : null, - characterSelectedAt: input.kind === 'add_action' ? createdAt : null, - actionInput: - input.kind === 'add_action' - ? { - id: createId(), - name: input.actionName.trim(), - type: input.actionType, - prompt: input.actionPrompt?.trim() || null, - fps: input.fps, - } - : null, + characterInput: { + prompt: input.characterPrompt.trim(), + referenceMedia: input.referenceMedia ?? [], + }, + characterId: null, + outfitId: null, + characterSelectedAt: null, + actionInputs: {}, createdAt, }, ], @@ -601,13 +625,14 @@ function createInitialSnapshot( } } -function validateActionInput( - input: ConfigureWorkflowActionInput | Extract, -): void { +function validateActionInput(input: ConfigureWorkflowActionInput): void { if (!input.actionName.trim()) throw new TypeError('动作名称不能为空') if (!Number.isFinite(input.fps) || input.fps <= 0) throw new TypeError('FPS 必须大于 0') - if ('characterId' in input && (!input.characterId.trim() || !input.outfitId.trim())) { - throw new TypeError('追加动作必须绑定角色和造型') +} + +function validateCharacterBinding(input: AppendWorkflowActionInput): void { + if (!input.projectId.trim() || !input.characterId.trim() || !input.outfitId.trim()) { + throw new TypeError('追加动作必须绑定项目、角色和造型') } } @@ -623,6 +648,12 @@ function requireActiveStep(run: WorkflowRunSnapshot): WorkflowStep { return step } +function requireActiveActionStep(run: WorkflowRunSnapshot): WorkflowStep { + const step = requireActiveStep(run) + if (step.type !== 'action') throw new Error('WorkflowRun 当前活动卡片不是动作') + return step +} + function currentRevision(run: WorkflowRunSnapshot): WorkflowRevision { const revision = run.revisions.find((item) => item.id === run.currentRevisionId) if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') @@ -637,22 +668,44 @@ function assertCurrentRevision(run: WorkflowRunSnapshot, revisionId: string): vo function createRestartedStep( source: WorkflowStep, - index: number, - restartIndex: number, + restart: boolean, createId: () => string, ): WorkflowStep { const step = structuredClone(source) step.id = createId() - if (index < restartIndex) return step + if (!restart) return step step.generations = [] step.error = null - step.status = index === restartIndex ? 'active' : 'locked' + step.status = 'active' step.phase = source.type === 'character' ? 'generating_character_candidates' : 'configuring_action' return step } +function createActionStep(createId: () => string, active: boolean): WorkflowStep { + const id = createId() + return { + id, + nodeId: `builtin-action:${id}`, + type: 'action', + status: active ? 'active' : 'locked', + phase: active ? 'generating_action_candidates' : 'configuring_action', + generations: [], + error: null, + } +} + +function createActionInput(input: ConfigureWorkflowActionInput, createId: () => string) { + return { + id: createId(), + name: input.actionName.trim(), + type: input.actionType, + prompt: input.actionPrompt?.trim() || null, + fps: input.fps, + } +} + function assertActive(run: WorkflowRunSnapshot): void { if (run.status !== 'active') throw new Error('WorkflowRun 当前不能继续推进') } @@ -669,7 +722,8 @@ function requireCharacterBinding(run: WorkflowRunSnapshot): { } function requireActionInput(run: WorkflowRunSnapshot) { - const actionInput = currentRevision(run).actionInput + const revision = currentRevision(run) + const actionInput = revision.actionInputs[requireActiveActionStep(run).id] if (!actionInput) throw new Error('WorkflowRun 尚未配置动作') return actionInput } diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index fbca64a8..94c77078 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -44,7 +44,7 @@ function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { characterId: null, outfitId: null, characterSelectedAt: null, - actionInput: null, + actionInputs: {}, }, ], createdAt: '2026-08-05T00:00:00.000Z', @@ -90,6 +90,37 @@ describe('WorkflowRunRepository', () => { await expect(restored.get('run-1')).resolves.toEqual(createSnapshot()) }) + it('finds the single run bound to a character image', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + const snapshot = createSnapshot() + const revision = snapshot.revisions[0]! + revision.steps[0]!.status = 'passed' + revision.steps[0]!.phase = 'completed' + revision.steps[0]!.generations = [ + { taskId: 'character-generation-1', role: 'character_candidates' }, + ] + revision.steps[1]!.status = 'active' + revision.characterId = 'character-1' + revision.outfitId = 'outfit-1' + revision.characterSelectedAt = '2026-08-05T00:01:00.000Z' + await repository.create(snapshot) + + await expect( + repository.findByCharacter({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'outfit-1', + }), + ).resolves.toMatchObject({ id: snapshot.id }) + await expect( + repository.findByCharacter({ + projectId: 'project-1', + characterId: 'character-1', + outfitId: 'another-outfit', + }), + ).resolves.toBeNull() + }) + it('returns clones so callers cannot mutate persisted state without save', async () => { const repository = createWorkflowRunRepository({ storage: null }) await repository.create(createSnapshot()) @@ -126,7 +157,7 @@ describe('WorkflowRunRepository', () => { characterId: parent.characterId, outfitId: parent.outfitId, characterSelectedAt: parent.characterSelectedAt, - actionInput: structuredClone(parent.actionInput), + actionInputs: {}, } snapshot.revisions.push(revision) snapshot.currentRevisionId = revision.id diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index 3bfbbce1..e4461bef 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -3,6 +3,7 @@ import type { WorkflowGenerationRef, WorkflowRevision, + WorkflowRunCharacterBinding, WorkflowRunSnapshot, WorkflowStep, } from '../model' @@ -18,7 +19,7 @@ import { } from '../model/constants' export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' -export const WORKFLOW_RUN_STORAGE_VERSION = 5 +export const WORKFLOW_RUN_STORAGE_VERSION = 6 const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const @@ -34,6 +35,7 @@ interface WorkflowRunStorage { export interface WorkflowRunRepository { create(run: WorkflowRunSnapshot): Promise get(runId: WorkflowRunSnapshot['id']): Promise + findByCharacter(binding: WorkflowRunCharacterBinding): Promise list(projectId?: string): Promise save(run: WorkflowRunSnapshot): Promise } @@ -142,8 +144,10 @@ function hasValidStepLine( rootNodeId: string, ): boolean { if ( - steps.length !== expectedOrder.length || - !steps.every((step, index) => isWorkflowStep(step, expectedOrder[index]!)) || + steps.length < expectedOrder.length || + !steps.every((step, index) => + isWorkflowStep(step, index === 0 ? expectedOrder[0]! : 'action'), + ) || new Set(steps.map((step) => step.id)).size !== steps.length || new Set(steps.map((step) => step.nodeId)).size !== steps.length || rootNodeId !== steps[0]?.nodeId @@ -153,23 +157,22 @@ function hasValidStepLine( if (steps.every((step) => step.status === 'passed')) return true - const currentIndex = steps.findIndex( - (step) => step.status === 'active' || step.status === 'failed', + const currentSteps = steps.filter((step) => step.status === 'active' || step.status === 'failed') + if (currentSteps.length !== 1) return false + const current = currentSteps[0]! + if (current.type === 'character') { + return steps[0] === current && steps.slice(1).every((step) => step.status === 'locked') + } + return ( + steps[0]?.status === 'passed' && + steps.every((step) => step === current || step.status === 'passed') ) - if (currentIndex < 0) return false - - return steps.every((step, index) => { - if (index < currentIndex) return step.status === 'passed' - if (index === currentIndex) return true - return step.status === 'locked' - }) } function isWorkflowRevision( value: unknown, expectedOrder: readonly string[], rootNodeId: string, - runKind: WorkflowRunSnapshot['source']['key'], ): value is WorkflowRevision { return ( isRecord(value) && @@ -178,7 +181,7 @@ function isWorkflowRevision( isNullableString(value.restartedFromStepId) && Array.isArray(value.steps) && hasValidStepLine(value.steps as WorkflowStep[], expectedOrder, rootNodeId) && - hasValidInputs(value as unknown as WorkflowRevision, runKind) && + hasValidInputs(value as unknown as WorkflowRevision) && isNonEmptyString(value.createdAt) ) } @@ -188,11 +191,11 @@ function hasValidRevisions(run: WorkflowRunSnapshot): boolean { if ( run.revisions.length === 0 || !run.revisions.every((revision) => - isWorkflowRevision(revision, expectedOrder, run.source.rootNodeId, run.source.key), + isWorkflowRevision(revision, expectedOrder, run.source.rootNodeId), ) || new Set(run.revisions.map((revision) => revision.id)).size !== run.revisions.length || new Set(run.revisions.flatMap((revision) => revision.steps.map((step) => step.id))).size !== - run.revisions.length * expectedOrder.length + run.revisions.reduce((total, revision) => total + revision.steps.length, 0) ) { return false } @@ -231,10 +234,7 @@ function isMediaReference(value: unknown): boolean { return isNonEmptyString(value) } -function hasValidInputs( - revision: WorkflowRevision, - runKind: WorkflowRunSnapshot['source']['key'], -): boolean { +function hasValidInputs(revision: WorkflowRevision): boolean { const characterInput = revision.characterInput as unknown const characterInputValid = characterInput === null || @@ -254,32 +254,37 @@ function hasValidInputs( isNonEmptyString(revision.characterSelectedAt) if (!characterIsEmpty && !characterIsSelected) return false - const actionInput = revision.actionInput as unknown - const actionValid = - actionInput === null || - (isRecord(actionInput) && - isNonEmptyString(actionInput.id) && - isNonEmptyString(actionInput.name) && - isMember(actionInput.type, ACTION_TYPES) && - isNullableString(actionInput.prompt) && - Number.isFinite(actionInput.fps) && - typeof actionInput.fps === 'number' && - actionInput.fps > 0) - if (!actionValid) return false - - if (runKind === 'character_action') { - if (revision.characterInput === null) return false - if (revision.actionInput !== null && !characterIsSelected) return false - } else if ( - revision.characterInput !== null || - !characterIsSelected || - revision.actionInput === null + if (revision.characterInput === null || !isRecord(revision.actionInputs)) return false + const actionSteps = revision.steps.filter((step) => step.type === 'action') + const actionStepIds = new Set(actionSteps.map((step) => step.id)) + const entries = Object.entries(revision.actionInputs) + if ( + entries.some( + ([stepId, input]) => + !actionStepIds.has(stepId) || + !isRecord(input) || + !isNonEmptyString(input.id) || + !isNonEmptyString(input.name) || + !isMember(input.type, ACTION_TYPES) || + !isNullableString(input.prompt) || + typeof input.fps !== 'number' || + !Number.isFinite(input.fps) || + input.fps <= 0, + ) ) { return false } - const completed = revision.steps.every((step) => step.status === 'passed') - return !completed || (characterIsSelected && revision.actionInput !== null) + if (characterIsEmpty) { + return entries.length === 0 && actionSteps.every((step) => step.status === 'locked') + } + if (!characterIsSelected) return false + return actionSteps.every( + (step) => + step.status === 'locked' || + (step.status === 'active' && step.phase === 'configuring_action') || + Object.hasOwn(revision.actionInputs, step.id), + ) } export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnapshot { @@ -373,6 +378,20 @@ export function createWorkflowRunRepository( const run = runs.get(runId) return run ? structuredClone(run) : null }, + async findByCharacter(binding) { + const run = [...runs.values()] + .filter((candidate) => candidate.projectId === binding.projectId) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .find((candidate) => { + const revision = candidate.revisions.find( + (item) => item.id === candidate.currentRevisionId, + ) + return ( + revision?.characterId === binding.characterId && revision.outfitId === binding.outfitId + ) + }) + return run ? structuredClone(run) : null + }, async list(projectId) { return [...runs.values()] .filter((run) => projectId === undefined || run.projectId === projectId) From 4e1408565e931a4cdb17c850595ca5ddd8554058 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:29:28 +0800 Subject: [PATCH 21/27] feat(workflow-controller): append actions to character run --- .../features/workflow-controller/README.md | 4 +- .../workflow-controller/controller.test.ts | 49 +++++++++---------- .../workflow-controller/controller.ts | 11 +++-- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index 3ff57473..efc54a8f 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -26,7 +26,7 @@ Quick Start / Workflow Editor | --- | --- | --- | | 开始生成角色 | `startCharacter` | 返回四张角色候选 | | 选定角色 | `confirmCharacter` | 进入动作配置 | -| 给已有角色追加动作 | `startAction` | 返回四张动作首帧候选 | +| 给已有角色图追加动作 | `startAction` | 在原 Run 追加 Action Step,返回四张首帧候选 | | 配置首个动作 | `configureAction` | 保存检查点并生成首帧候选 | | 选定动作首帧 | `confirmActionFirstFrame` | 恢复完整动画并进入审核 | | 审核通过 | `approveAction` | 将动作写回 Character | @@ -37,6 +37,8 @@ Quick Start / Workflow Editor - 不保存第二份 WorkflowRun 快照,也不直接访问 Repository 或 localStorage。实例缓存只用于保证 同一页面会话中的命令操作同一个绑定 Run;页面刷新会重新创建 Controller 并从 Service 恢复。 - 不提供 `subscribe` / `subscribeAll`;页面操作本身知道状态何时改变。 +- 不为新增动作创建第二个 Run;角色图归属由 WorkflowRun Service 按 + `projectId + characterId + outfitId` 统一定位。 - 不解释 GenerationTask 的内部执行方式,不整理最终模型提示词。 - 不直接调用模型、媒体存储或 Character API,这些由 WorkflowRun Entity 组合。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 38e9577e..d928f10d 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -19,19 +19,16 @@ function createSnapshot( phase: WorkflowStepPhase, options: { id?: string - kind?: 'character_action' | 'add_action' status?: WorkflowRunSnapshot['status'] } = {}, ): WorkflowRunSnapshot { const id = options.id ?? 'run-1' - const kind = options.kind ?? (phase.includes('character') ? 'character_action' : 'add_action') const status = options.status ?? 'active' const isCharacterPhase = phase === 'generating_character_candidates' || phase === 'selecting_character' const isTerminal = status === 'completed' - const steps: WorkflowStep[] = [] - if (kind === 'character_action') { - steps.push({ + const steps: WorkflowStep[] = [ + { id: 'character-step', nodeId: 'builtin-character', type: 'character', @@ -39,9 +36,9 @@ function createSnapshot( phase: isTerminal || !isCharacterPhase ? 'completed' : phase, generations: [], error: null, - }) - } - if (isCharacterPhase && kind === 'character_action') { + }, + ] + if (isCharacterPhase) { steps.push({ id: 'action-step', nodeId: 'builtin-action', @@ -68,8 +65,8 @@ function createSnapshot( projectId: 'project-1', source: { type: 'builtin', - key: kind, - rootNodeId: kind === 'character_action' ? 'builtin-character' : 'builtin-action', + key: 'character_action', + rootNodeId: 'builtin-character', }, status, currentRevisionId: 'revision-1', @@ -79,20 +76,21 @@ function createSnapshot( parentRevisionId: null, restartedFromStepId: null, steps, - characterInput: - kind === 'character_action' ? { prompt: '像素风守夜人', referenceMedia: [] } : null, + characterInput: { prompt: '像素风守夜人', referenceMedia: [] }, characterId: isCharacterPhase ? null : 'character-1', outfitId: isCharacterPhase ? null : 'outfit-1', characterSelectedAt: isCharacterPhase ? null : '2026-08-06T00:00:00.000Z', - actionInput: + actionInputs: isCharacterPhase || phase === 'configuring_action' - ? null + ? {} : { - id: 'action-1', - name: '行走', - type: 'walk', - prompt: null, - fps: 12, + 'action-step': { + id: 'action-1', + name: '行走', + type: 'walk', + prompt: null, + fps: 12, + }, }, createdAt: '2026-08-06T00:00:00.000Z', }, @@ -129,6 +127,7 @@ function createRun(initial: WorkflowRunSnapshot): WorkflowRun { resumeCharacterCandidates: vi.fn(), confirmCharacter: vi.fn(), configureAction: vi.fn(), + appendAction: vi.fn(), resumeActionFirstFrameCandidates: vi.fn(), confirmActionFirstFrame: vi.fn(), resumeAction: vi.fn(), @@ -143,6 +142,7 @@ function createFixture(runs: WorkflowRun[] = []) { const service = { create: vi.fn(), get: vi.fn(async (runId: string) => byId.get(runId) ?? null), + appendAction: vi.fn(), } as unknown as WorkflowRunService return { service, controller: createWorkflowController({ service }) } } @@ -180,24 +180,23 @@ function deferred() { } describe('WorkflowController', () => { - it('creates the correct run kind and returns its candidate batch', async () => { + it('creates a character run and appends actions to that same run', async () => { const characterRun = createRun(createSnapshot('generating_character_candidates')) - const actionRun = createRun(createSnapshot('generating_action_candidates', { id: 'run-2' })) + const actionRun = createRun(createSnapshot('generating_action_candidates')) vi.mocked(characterRun.start).mockResolvedValue(characterBatch(characterRun.snapshot())) vi.mocked(actionRun.start).mockResolvedValue(actionBatch(actionRun.snapshot())) const { controller, service } = createFixture() - vi.mocked(service.create).mockResolvedValueOnce(characterRun).mockResolvedValueOnce(actionRun) + vi.mocked(service.create).mockResolvedValue(characterRun) + vi.mocked(service.appendAction).mockResolvedValue(actionRun) await expect( controller.startCharacter({ - kind: 'character_action', projectId: 'project-1', characterPrompt: '像素风守夜人', }), ).resolves.toMatchObject({ generationId: 'character-generation-1' }) await expect( controller.startAction({ - kind: 'add_action', projectId: 'project-1', characterId: 'character-1', outfitId: 'outfit-1', @@ -217,7 +216,7 @@ describe('WorkflowController', () => { }) it('confirms the character and checkpoints action configuration before generation', async () => { - const run = createRun(createSnapshot('configuring_action', { kind: 'character_action' })) + const run = createRun(createSnapshot('configuring_action')) const snapshot = run.snapshot() vi.mocked(run.confirmCharacter).mockResolvedValue(snapshot) vi.mocked(run.resumeActionFirstFrameCandidates).mockResolvedValue(actionBatch(snapshot)) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index d7360e2a..0028ac31 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -9,6 +9,7 @@ import type { ActionFirstFrameCandidateBatch, ActionReviewResult, + AppendWorkflowActionInput, CharacterCandidateBatch, ConfigureWorkflowActionInput, CreateWorkflowRunInput, @@ -19,8 +20,8 @@ import type { WorkflowStep, } from '@/entities' -type CharacterRunInput = Extract -type AddActionRunInput = Extract +type CharacterRunInput = CreateWorkflowRunInput +type AddActionInput = AppendWorkflowActionInput /** 页面恢复后可直接渲染的状态;候选 URL 和审核帧不写入 WorkflowRun 快照。 */ export type WorkflowControllerSnapshot = @@ -41,8 +42,8 @@ export interface WorkflowController { runId: WorkflowRun['id'], selectedImageUrl: string, ): Promise> - /** 从已有角色创建追加动作 Run,并开始生成四张动作首帧候选。 */ - startAction(input: AddActionRunInput): Promise + /** 在该角色图原有 Run 中追加动作卡片,并开始生成四张动作首帧候选。 */ + startAction(input: AddActionInput): Promise /** 为角色创建 Run 配置首个动作,并开始生成动作首帧候选。 */ configureAction( runId: WorkflowRun['id'], @@ -153,7 +154,7 @@ export function createWorkflowController({ return { phase: 'action-setup', snapshot } }, async startAction(input) { - const run = rememberRun(await service.create(input)) + const run = rememberRun(await service.appendAction(input)) const result = await run.start() if (!isActionBatch(result)) throw new Error('动作 Run 没有返回首帧候选') return result From 689c9a3c3c89f5c0e7b2e2fde8b8120365ae90ba Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:44:38 +0800 Subject: [PATCH 22/27] feat(workflow-run): sync versioned persistence for controller --- frontend/src/entities/workflow-run/index.ts | 2 + .../src/entities/workflow-run/model/index.ts | 2 + .../src/entities/workflow-run/model/types.ts | 15 +++ .../service/workflow-run-service.test.ts | 52 ++++++++++- .../service/workflow-run-service.ts | 86 ++++++++++++++++- .../store/workflow-run-store.test.ts | 40 +++++++- .../workflow-run/store/workflow-run-store.ts | 92 ++++++++++++++++--- 7 files changed, 268 insertions(+), 21 deletions(-) diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index d4170b7d..ec24b432 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -7,11 +7,13 @@ export { } from './model' export type { AppendWorkflowActionInput, + AcceptUploadedCharacterTemplateInput, BuiltinWorkflowRunSource, ConfigureWorkflowActionInput, CreateWorkflowRunInput, WorkflowActionInput, WorkflowCharacterInput, + WorkflowCharacterOrigin, WorkflowGenerationRef, WorkflowGenerationRole, WorkflowRunKind, diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts index 83184839..fb7ac4fa 100644 --- a/frontend/src/entities/workflow-run/model/index.ts +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -6,12 +6,14 @@ export { WORKFLOW_STEP_ORDERS, } from './constants' export type { + AcceptUploadedCharacterTemplateInput, AppendWorkflowActionInput, BuiltinWorkflowRunSource, ConfigureWorkflowActionInput, CreateWorkflowRunInput, WorkflowActionInput, WorkflowCharacterInput, + WorkflowCharacterOrigin, WorkflowGenerationRef, WorkflowGenerationRole, WorkflowRunKind, diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index 8a78e56d..c039def2 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -18,6 +18,7 @@ export type WorkflowStepType = (typeof WORKFLOW_STEP_TYPES)[number] export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] export type WorkflowStepPhase = (typeof WORKFLOW_STEP_PHASES)[number] export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] +export type WorkflowCharacterOrigin = 'generated' | 'uploaded' /** * Step 对后端 GenerationTask 的引用。 @@ -60,6 +61,8 @@ export interface WorkflowRevision { /** 节点输入和产出属于执行分支,不能放在 Run 顶层覆盖旧 Revision。 */ characterInput: WorkflowCharacterInput | null + /** 生成候选确认和用户上传是两条不同来源,不能用假的 GenerationTask 混在一起。 */ + characterOrigin: WorkflowCharacterOrigin | null characterId: string | null outfitId: string | null characterSelectedAt: string | null @@ -101,6 +104,11 @@ export interface WorkflowActionInput { export interface WorkflowRunSnapshot { id: string projectId: string + /** + * 持久化并发版本,与下面的 WorkflowRevision 不是同一个概念。 + * Repository 每次成功保存后加一,用于阻止旧页面覆盖较新的运行快照。 + */ + version: number source: BuiltinWorkflowRunSource status: WorkflowRunStatus currentRevisionId: WorkflowRevision['id'] @@ -137,3 +145,10 @@ export interface WorkflowRunCharacterBinding { characterId: string outfitId: string } + +export interface AcceptUploadedCharacterTemplateInput { + referenceMedia: MediaReference + /** 为空时沿用创建 Run 时的角色描述。 */ + description?: string | null + name?: string | null +} diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index 1f5dd701..0a2152e0 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Character, CharacterApis } from '../../character' import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' +import type { MediaReference } from '../../media' import type { WorkflowRunSnapshot } from '../model' import { createWorkflowRunRepository } from '../store' import { @@ -192,7 +193,13 @@ describe('WorkflowRun instance', () => { it('binds operations to the run instance and exposes character-scoped action append', async () => { const { service } = createFixture() - expect(Object.keys(service).sort()).toEqual(['appendAction', 'create', 'get']) + expect(Object.keys(service).sort()).toEqual([ + 'appendAction', + 'create', + 'get', + 'getByCharacterOutfit', + 'listByCharacter', + ]) const created = await service.create({ projectId: 'project-1', characterPrompt: '角色', @@ -200,6 +207,13 @@ describe('WorkflowRun instance', () => { const restored = await service.get(created.id) expect(restored?.id).toBe(created.id) expect(restored?.snapshot()).toEqual(created.snapshot()) + await expect( + service.getByCharacterOutfit({ + projectId: 'project-1', + characterId: 'missing-character', + outfitId: 'missing-outfit', + }), + ).resolves.toBeNull() }) it('appends every new action for the same character image to one run', async () => { @@ -244,6 +258,42 @@ describe('WorkflowRun instance', () => { ]) }) + it('turns an uploaded template into a formal character binding without a fake generation', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + projectId: 'project-1', + characterPrompt: '上传的像素守夜人', + }) + + const accepted = await run.acceptUploadedCharacterTemplate({ + referenceMedia: 'media://uploaded-template' as MediaReference, + description: '用户上传的像素守夜人', + }) + const revision = currentRevisionSnapshot(accepted) + + expect(revision).toMatchObject({ + characterOrigin: 'uploaded', + characterId: 'character-1', + outfitId: 'outfit-1', + }) + expect(revision.steps[0]).toMatchObject({ + type: 'character', + status: 'passed', + phase: 'completed', + generations: [], + }) + expect(revision.steps[1]).toMatchObject({ + type: 'action', + status: 'active', + phase: 'configuring_action', + }) + expect(fixture.characterApis.create).toHaveBeenCalledWith( + expect.objectContaining({ referenceImageUrl: 'media://uploaded-template' }), + ) + expect(fixture.generation.create).not.toHaveBeenCalled() + await expect(fixture.repository.get(run.id)).resolves.toMatchObject({ version: 2 }) + }) + it('keeps ordinary state transitions local until save is explicitly requested', async () => { const { service, repository } = createFixture() const run = await service.create({ diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index d38af506..753421b7 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -10,11 +10,13 @@ import type { } from '../../generation' import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' import type { + AcceptUploadedCharacterTemplateInput, AppendWorkflowActionInput, ConfigureWorkflowActionInput, CreateWorkflowRunInput, WorkflowGenerationRole, WorkflowRevision, + WorkflowRunCharacterBinding, WorkflowRunSnapshot, WorkflowStep, WorkflowStepType, @@ -77,6 +79,9 @@ export interface WorkflowRun { start(): Promise resumeCharacterCandidates(): Promise confirmCharacter(selectedImageUrl: string): Promise + acceptUploadedCharacterTemplate( + input: AcceptUploadedCharacterTemplateInput, + ): Promise configureAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot /** 给当前角色图追加动作卡片;不会创建新的 WorkflowRun。 */ appendAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot @@ -91,6 +96,8 @@ export interface WorkflowRun { export interface WorkflowRunService { create(input: CreateWorkflowRunInput): Promise get(runId: WorkflowRunSnapshot['id']): Promise + getByCharacterOutfit(binding: WorkflowRunCharacterBinding): Promise + listByCharacter(characterId: string): Promise /** 根据角色图找到原 Run,并追加动作;找不到时失败,绝不偷偷创建第二个 Run。 */ appendAction(input: AppendWorkflowActionInput): Promise } @@ -123,7 +130,7 @@ export function createWorkflowRunService( return current() } const persist = async (): Promise => { - state = await options.repository.save(state) + state = await options.repository.save(state, state.version) return current() } const mutate = (edit: (draft: WorkflowRunSnapshot) => void): WorkflowRunSnapshot => { @@ -326,6 +333,7 @@ export function createWorkflowRunService( restartedFromStepId: stepId, steps, characterInput: structuredClone(parent.characterInput), + characterOrigin: parent.characterOrigin, characterId: parent.characterId, outfitId: parent.outfitId, characterSelectedAt: parent.characterSelectedAt, @@ -336,6 +344,7 @@ export function createWorkflowRunService( revision.characterId = null revision.outfitId = null revision.characterSelectedAt = null + revision.characterOrigin = null } return mutate((draft) => { draft.revisions.push(revision) @@ -373,6 +382,7 @@ export function createWorkflowRunService( characterStep.status = 'passed' characterStep.phase = 'completed' const revision = currentRevision(draft) + revision.characterOrigin = 'generated' revision.characterId = confirmed.character.id revision.outfitId = confirmed.outfitId revision.characterSelectedAt = now() @@ -384,6 +394,69 @@ export function createWorkflowRunService( actionStep.phase = 'configuring_action' }) }, + async acceptUploadedCharacterTemplate(input) { + assertActive(state) + const revisionId = state.currentRevisionId + const revision = currentRevision(state) + const characterStep = requireStep(state, 'character') + if ( + characterStep.phase !== 'generating_character_candidates' || + characterStep.generations.length > 0 || + revision.characterOrigin !== null + ) { + throw new Error('当前角色卡片不能采用上传母版') + } + const referenceImageUrl = String(input.referenceMedia).trim() + if (!referenceImageUrl) throw new TypeError('上传母版引用不能为空') + const description = input.description?.trim() || revision.characterInput?.prompt || null + + let character = await options.characterApis.create({ + projectId: state.projectId, + name: input.name?.trim() || null, + description, + referenceImageUrl, + }) + assertCurrentRevision(state, revisionId) + if (character.outfits.length === 0) { + character = await options.characterApis.update({ + ...character, + outfits: [ + { + id: `outfit-${character.id}-default`, + characterId: character.id, + name: '默认造型', + description: null, + previewUrl: referenceImageUrl, + actions: [], + }, + ], + }) + assertCurrentRevision(state, revisionId) + } + const outfitId = character.outfits[0]?.id + if (!outfitId) throw new Error('角色服务没有返回可用造型') + + return checkpoint((draft) => { + assertCurrentRevision(draft, revisionId) + const draftRevision = currentRevision(draft) + if (!draftRevision.characterInput) throw new Error('WorkflowRun 缺少角色输入') + draftRevision.characterInput.referenceMedia = [input.referenceMedia] + if (description) draftRevision.characterInput.prompt = description + draftRevision.characterOrigin = 'uploaded' + draftRevision.characterId = character.id + draftRevision.outfitId = outfitId + draftRevision.characterSelectedAt = now() + const draftCharacterStep = requireStep(draft, 'character') + draftCharacterStep.status = 'passed' + draftCharacterStep.phase = 'completed' + const actionStep = draftRevision.steps.find( + (step) => step.type === 'action' && step.status === 'locked', + ) + if (!actionStep) throw new Error('WorkflowRun 缺少首个动作卡片') + actionStep.status = 'active' + actionStep.phase = 'configuring_action' + }) + }, configureAction(input) { assertActive(state) const step = requireActiveActionStep(state) @@ -563,10 +636,17 @@ export function createWorkflowRunService( const state = await options.repository.get(runId) return state ? bind(state) : null }, + async getByCharacterOutfit(binding) { + const state = await options.repository.getByCharacterOutfit(binding) + return state ? bind(state) : null + }, + listByCharacter(characterId) { + return options.repository.listByCharacter(characterId) + }, async appendAction(input) { validateCharacterBinding(input) validateActionInput(input) - const state = await options.repository.findByCharacter(input) + const state = await options.repository.getByCharacterOutfit(input) if (!state) throw new Error('该角色图没有可追加动作的 WorkflowRun') const run = bind(state) run.appendAction(input) @@ -600,6 +680,7 @@ function createInitialSnapshot( return { id: createId(), projectId: input.projectId.trim(), + version: 1, source: { type: 'builtin', key: 'character_action', rootNodeId: steps[0]!.nodeId }, status: 'active', currentRevisionId: initialRevisionId, @@ -613,6 +694,7 @@ function createInitialSnapshot( prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [], }, + characterOrigin: null, characterId: null, outfitId: null, characterSelectedAt: null, diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index 94c77078..0713269e 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -11,6 +11,7 @@ function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { return { id, projectId: 'project-1', + version: 1, source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, status: 'active', currentRevisionId: 'revision-1', @@ -41,6 +42,7 @@ function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { }, ], characterInput: { prompt: '像素骑士', referenceMedia: [] }, + characterOrigin: null, characterId: null, outfitId: null, characterSelectedAt: null, @@ -74,7 +76,10 @@ describe('WorkflowRunRepository', () => { await createdPromise await expect(repository.get('run-1')).resolves.toMatchObject({ id: 'run-1' }) await expect(repository.list('project-1')).resolves.toHaveLength(1) - await expect(repository.save(createSnapshot())).resolves.toMatchObject({ id: 'run-1' }) + await expect(repository.save(createSnapshot(), 1)).resolves.toMatchObject({ + id: 'run-1', + version: 2, + }) }) it('persists a versioned snapshot and hydrates it in a new repository', async () => { @@ -103,22 +108,46 @@ describe('WorkflowRunRepository', () => { revision.characterId = 'character-1' revision.outfitId = 'outfit-1' revision.characterSelectedAt = '2026-08-05T00:01:00.000Z' + revision.characterOrigin = 'generated' await repository.create(snapshot) await expect( - repository.findByCharacter({ + repository.getByCharacterOutfit({ projectId: 'project-1', characterId: 'character-1', outfitId: 'outfit-1', }), ).resolves.toMatchObject({ id: snapshot.id }) await expect( - repository.findByCharacter({ + repository.getByCharacterOutfit({ projectId: 'project-1', characterId: 'character-1', outfitId: 'another-outfit', }), ).resolves.toBeNull() + await expect(repository.listByCharacter('character-1')).resolves.toEqual([ + expect.objectContaining({ id: snapshot.id }), + ]) + await expect(repository.listByCharacter('another-character')).resolves.toEqual([]) + }) + + it('rejects a stale save instead of overwriting a newer snapshot', async () => { + const storage = createMemoryStorage() + const firstRepository = createWorkflowRunRepository({ storage }) + const secondRepository = createWorkflowRunRepository({ storage }) + await firstRepository.create(createSnapshot()) + + const firstCopy = (await firstRepository.get('run-1'))! + const staleCopy = (await secondRepository.get('run-1'))! + firstCopy.status = 'interrupted' + const saved = await firstRepository.save(firstCopy, firstCopy.version) + expect(saved.version).toBe(2) + + staleCopy.status = 'interrupted' + await expect(secondRepository.save(staleCopy, staleCopy.version)).rejects.toThrow( + '已被其他操作更新', + ) + await expect(secondRepository.get('run-1')).resolves.toMatchObject({ version: 2 }) }) it('returns clones so callers cannot mutate persisted state without save', async () => { @@ -138,7 +167,9 @@ describe('WorkflowRunRepository', () => { const invalid = createSnapshot('run-invalid') invalid.revisions[0]!.steps[0]!.phase = 'reviewing_animation' expect(isWorkflowRunSnapshot(invalid)).toBe(false) - await expect(repository.save(invalid)).rejects.toThrow('Invalid WorkflowRun snapshot') + await expect(repository.save(invalid, invalid.version)).rejects.toThrow( + 'Invalid WorkflowRun snapshot', + ) }) it('hydrates revision lineage only when parents and restart steps are valid', () => { @@ -154,6 +185,7 @@ describe('WorkflowRunRepository', () => { id: `step-v2-${index}`, })), characterInput: structuredClone(parent.characterInput), + characterOrigin: parent.characterOrigin, characterId: parent.characterId, outfitId: parent.outfitId, characterSelectedAt: parent.characterSelectedAt, diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index e4461bef..69b841f4 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -19,9 +19,10 @@ import { } from '../model/constants' export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' -export const WORKFLOW_RUN_STORAGE_VERSION = 6 +export const WORKFLOW_RUN_STORAGE_VERSION = 7 const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const +const CHARACTER_ORIGINS = ['generated', 'uploaded'] as const interface WorkflowRunStorage { getItem(key: string): string | null @@ -35,9 +36,13 @@ interface WorkflowRunStorage { export interface WorkflowRunRepository { create(run: WorkflowRunSnapshot): Promise get(runId: WorkflowRunSnapshot['id']): Promise - findByCharacter(binding: WorkflowRunCharacterBinding): Promise + getByCharacterOutfit( + binding: WorkflowRunCharacterBinding, + ): Promise + listByCharacter(characterId: string): Promise list(projectId?: string): Promise - save(run: WorkflowRunSnapshot): Promise + /** expectedVersion 必须等于当前持久化版本;成功后返回 version + 1 的快照。 */ + save(run: WorkflowRunSnapshot, expectedVersion: number): Promise } export interface CreateWorkflowRunRepositoryOptions { @@ -96,7 +101,9 @@ function hasValidGenerationRefs(step: WorkflowStep): boolean { return ( step.generations.every((item) => item.role === 'character_candidates') && count <= 1 && - (step.phase === 'generating_character_candidates' || count === 1) + (step.phase === 'generating_character_candidates' || + step.phase === 'completed' || + count === 1) ) } @@ -245,15 +252,29 @@ function hasValidInputs(revision: WorkflowRevision): boolean { if (!characterInputValid) return false const characterIsEmpty = + revision.characterOrigin === null && revision.characterId === null && revision.outfitId === null && revision.characterSelectedAt === null const characterIsSelected = + isMember(revision.characterOrigin, CHARACTER_ORIGINS) && isNonEmptyString(revision.characterId) && isNonEmptyString(revision.outfitId) && isNonEmptyString(revision.characterSelectedAt) if (!characterIsEmpty && !characterIsSelected) return false + const characterStep = revision.steps.find((step) => step.type === 'character') + const characterGenerationCount = characterStep?.generations.filter( + (generation) => generation.role === 'character_candidates', + ).length + if ( + characterIsSelected && + ((revision.characterOrigin === 'generated' && characterGenerationCount !== 1) || + (revision.characterOrigin === 'uploaded' && characterGenerationCount !== 0)) + ) { + return false + } + if (revision.characterInput === null || !isRecord(revision.actionInputs)) return false const actionSteps = revision.steps.filter((step) => step.type === 'action') const actionStepIds = new Set(actionSteps.map((step) => step.id)) @@ -292,6 +313,9 @@ export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnaps !isRecord(value) || !isNonEmptyString(value.id) || !isNonEmptyString(value.projectId) || + typeof value.version !== 'number' || + !Number.isSafeInteger(value.version) || + value.version < 1 || !isRecord(value.source) || value.source.type !== 'builtin' || !isMember(value.source.key, WORKFLOW_RUN_KINDS) || @@ -345,6 +369,12 @@ export function createWorkflowRunRepository( const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) + function reload(): void { + if (storage === null) return + runs.clear() + for (const run of readPersistedRuns(storage)) runs.set(run.id, run) + } + function persist(): void { const payload: PersistedWorkflowRuns = { version: WORKFLOW_RUN_STORAGE_VERSION, @@ -353,32 +383,55 @@ export function createWorkflowRunRepository( storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(payload)) } - async function write( + async function create(run: WorkflowRunSnapshot): Promise { + if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') + reload() + if (runs.has(run.id)) throw new Error(`WorkflowRun 已存在:${run.id}`) + const saved = structuredClone(run) + runs.set(saved.id, saved) + try { + persist() + } catch (cause) { + runs.delete(saved.id) + throw new Error('WorkflowRun 本地持久化失败', { cause }) + } + return structuredClone(saved) + } + + async function save( run: WorkflowRunSnapshot, - requireNew: boolean, + expectedVersion: number, ): Promise { if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') - if (requireNew && runs.has(run.id)) throw new Error(`WorkflowRun 已存在:${run.id}`) - const saved = structuredClone(run) - const previous = runs.get(saved.id) + if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) { + throw new TypeError('expectedVersion 必须是正整数') + } + reload() + const previous = runs.get(run.id) + if (!previous) throw new Error(`WorkflowRun 不存在:${run.id}`) + if (run.version !== expectedVersion || previous.version !== expectedVersion) { + throw new Error('WorkflowRun 已被其他操作更新,请刷新后重试') + } + const saved = { ...structuredClone(run), version: expectedVersion + 1 } runs.set(saved.id, saved) try { persist() } catch (cause) { - if (previous === undefined) runs.delete(saved.id) - else runs.set(previous.id, previous) + runs.set(previous.id, previous) throw new Error('WorkflowRun 本地持久化失败', { cause }) } return structuredClone(saved) } return { - create: (run) => write(run, true), + create, async get(runId) { + reload() const run = runs.get(runId) return run ? structuredClone(run) : null }, - async findByCharacter(binding) { + async getByCharacterOutfit(binding) { + reload() const run = [...runs.values()] .filter((candidate) => candidate.projectId === binding.projectId) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) @@ -392,11 +445,22 @@ export function createWorkflowRunRepository( }) return run ? structuredClone(run) : null }, + async listByCharacter(characterId) { + reload() + if (!characterId.trim()) throw new TypeError('characterId 不能为空') + return [...runs.values()] + .filter((candidate) => + candidate.revisions.some((revision) => revision.characterId === characterId), + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map((run) => structuredClone(run)) + }, async list(projectId) { + reload() return [...runs.values()] .filter((run) => projectId === undefined || run.projectId === projectId) .map((run) => structuredClone(run)) }, - save: (run) => write(run, false), + save, } } From 80ebae3c27254a061ee6e2c5c89f0a3d3457db10 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:44:51 +0800 Subject: [PATCH 23/27] feat(workflow-controller): centralize phase progression --- .../features/workflow-controller/README.md | 12 +- .../workflow-controller/controller.test.ts | 103 +++++++++++++++++- .../workflow-controller/controller.ts | 103 ++++++++++++++++++ .../src/features/workflow-controller/index.ts | 1 + 4 files changed, 216 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index efc54a8f..018fbdce 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -12,6 +12,9 @@ Quick Start / Workflow Editor -> GenerationTask / Character API ``` +Quick Start Service 与 Workflow Editor Service 都只是页面适配器。两者分别依赖同一个 +WorkflowController,彼此之间不能调用,也不能把 Quick Start 的方法注入 Workflow Editor。 + ## 职责 - 把创建角色、追加动作、候选确认、审核通过和节点重做整理成页面命令。 @@ -19,18 +22,24 @@ Quick Start / Workflow Editor - 合并同一个 Run 的并发恢复请求,避免 React StrictMode 或路由重进重复恢复任务。 - 在一个 Controller 生命周期内复用同一个绑定 Run,使“生成中断”能作用于正在等待结果的实例。 - 在中断、继续、配置动作和节点重做后立即保存 WorkflowRun 检查点。 +- `nextStep` 根据当前卡片 phase 分流;缺少选图、动作配置或审核决定时只返回页面状态, + 不替用户作决定。 +- 上传母版时先调用注入的媒体上传函数;上传成功后再创建 Run,并让 WorkflowRun 立即创建、 + 绑定正式 Character/Outfit。 ## 页面命令 | 页面操作 | Controller 命令 | 结果 | | --- | --- | --- | | 开始生成角色 | `startCharacter` | 返回四张角色候选 | +| 从上传母版开始 | `startCharacterFromUploadedTemplate` | 上传成功后进入动作配置 | | 选定角色 | `confirmCharacter` | 进入动作配置 | | 给已有角色图追加动作 | `startAction` | 在原 Run 追加 Action Step,返回四张首帧候选 | | 配置首个动作 | `configureAction` | 保存检查点并生成首帧候选 | | 选定动作首帧 | `confirmActionFirstFrame` | 恢复完整动画并进入审核 | | 审核通过 | `approveAction` | 将动作写回 Character | | 页面刷新或重新进入 | `resume` | 恢复到可直接渲染的页面阶段 | +| 通用推进 | `nextStep` | 按当前 phase 返回或推进一个页面步骤 | ## 不负责 @@ -40,7 +49,8 @@ Quick Start / Workflow Editor - 不为新增动作创建第二个 Run;角色图归属由 WorkflowRun Service 按 `projectId + characterId + outfitId` 统一定位。 - 不解释 GenerationTask 的内部执行方式,不整理最终模型提示词。 -- 不直接调用模型、媒体存储或 Character API,这些由 WorkflowRun Entity 组合。 +- 不直接调用模型或 Character API,这些由 WorkflowRun Entity 组合;Controller 只调用注入的 + Media 上传边界,不接触具体存储供应商。 Controller 依赖 WorkflowRun 的异步 Service 接口,因此以后把本地 Repository 替换成后端接口时, 页面命令不需要从同步调用整体改写为异步调用。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index d928f10d..7d1766b6 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -6,6 +6,7 @@ import type { ActionFirstFrameCandidateBatch, ActionReviewResult, CharacterCandidateBatch, + MediaReference, PublishActionResult, WorkflowRun, WorkflowRunService, @@ -63,6 +64,7 @@ function createSnapshot( return { id, projectId: 'project-1', + version: 1, source: { type: 'builtin', key: 'character_action', @@ -77,6 +79,7 @@ function createSnapshot( restartedFromStepId: null, steps, characterInput: { prompt: '像素风守夜人', referenceMedia: [] }, + characterOrigin: isCharacterPhase ? null : 'generated', characterId: isCharacterPhase ? null : 'character-1', outfitId: isCharacterPhase ? null : 'outfit-1', characterSelectedAt: isCharacterPhase ? null : '2026-08-06T00:00:00.000Z', @@ -126,6 +129,7 @@ function createRun(initial: WorkflowRunSnapshot): WorkflowRun { start: vi.fn(), resumeCharacterCandidates: vi.fn(), confirmCharacter: vi.fn(), + acceptUploadedCharacterTemplate: vi.fn(), configureAction: vi.fn(), appendAction: vi.fn(), resumeActionFirstFrameCandidates: vi.fn(), @@ -137,14 +141,22 @@ function createRun(initial: WorkflowRunSnapshot): WorkflowRun { return run as unknown as WorkflowRun } -function createFixture(runs: WorkflowRun[] = []) { +function createFixture( + runs: WorkflowRun[] = [], + uploadCharacterTemplate?: (file: File, signal?: AbortSignal) => Promise, +) { const byId = new Map(runs.map((run) => [run.id, run])) const service = { create: vi.fn(), get: vi.fn(async (runId: string) => byId.get(runId) ?? null), + getByCharacterOutfit: vi.fn(), + listByCharacter: vi.fn(), appendAction: vi.fn(), } as unknown as WorkflowRunService - return { service, controller: createWorkflowController({ service }) } + return { + service, + controller: createWorkflowController({ service, uploadCharacterTemplate }), + } } function characterBatch(snapshot: WorkflowRunSnapshot): CharacterCandidateBatch { @@ -237,6 +249,42 @@ describe('WorkflowController', () => { expect(run.resumeActionFirstFrameCandidates).toHaveBeenCalledOnce() }) + it('keeps uploaded-template orchestration in the controller', async () => { + const createdRun = createRun(createSnapshot('generating_character_candidates')) + const existingRun = createRun( + createSnapshot('generating_character_candidates', { id: 'run-existing' }), + ) + const accepted = createSnapshot('configuring_action') + vi.mocked(createdRun.acceptUploadedCharacterTemplate).mockResolvedValue(accepted) + vi.mocked(existingRun.acceptUploadedCharacterTemplate).mockResolvedValue(accepted) + const upload = vi.fn(async () => 'media://template' as MediaReference) + const { controller, service } = createFixture([existingRun], upload) + vi.mocked(service.create).mockResolvedValue(createdRun) + const file = new File(['template'], 'template.png', { type: 'image/png' }) + + await expect( + controller.startCharacterFromUploadedTemplate( + { projectId: 'project-1', characterPrompt: '像素守夜人' }, + file, + ), + ).resolves.toEqual({ phase: 'action-setup', snapshot: accepted }) + expect(upload.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(service.create).mock.invocationCallOrder[0]!, + ) + expect(createdRun.acceptUploadedCharacterTemplate).toHaveBeenCalledWith({ + referenceMedia: 'media://template', + description: '像素守夜人', + }) + + await expect( + controller.continueWithUploadedTemplate(existingRun.id, file, '已有角色'), + ).resolves.toEqual({ phase: 'action-setup', snapshot: accepted }) + expect(existingRun.acceptUploadedCharacterTemplate).toHaveBeenCalledWith({ + referenceMedia: 'media://template', + description: '已有角色', + }) + }) + it('maps persisted phases to page snapshots', async () => { const characterRun = createRun(createSnapshot('selecting_character')) const actionRun = createRun(createSnapshot('selecting_action_frame', { id: 'run-2' })) @@ -261,6 +309,57 @@ describe('WorkflowController', () => { }) }) + it('uses nextStep as a phase-driven entry without inventing user choices', async () => { + const characterRun = createRun(createSnapshot('selecting_character')) + const actionRun = createRun(createSnapshot('configuring_action', { id: 'run-2' })) + const confirmedSnapshot = createSnapshot('configuring_action') + vi.mocked(characterRun.resumeCharacterCandidates).mockResolvedValue( + characterBatch(characterRun.snapshot()), + ) + vi.mocked(characterRun.confirmCharacter).mockResolvedValue(confirmedSnapshot) + vi.mocked(actionRun.resumeActionFirstFrameCandidates).mockResolvedValue( + actionBatch(actionRun.snapshot()), + ) + const { controller } = createFixture([characterRun, actionRun]) + + await expect(controller.nextStep(characterRun.id)).resolves.toMatchObject({ + phase: 'character-candidates', + }) + expect(characterRun.confirmCharacter).not.toHaveBeenCalled() + await expect( + controller.nextStep(characterRun.id, { selectedImageUrl: 'character-2.png' }), + ).resolves.toEqual({ phase: 'action-setup', snapshot: confirmedSnapshot }) + + await expect( + controller.nextStep(actionRun.id, { + action: { actionName: '行走', actionType: 'walk', fps: 12 }, + }), + ).resolves.toMatchObject({ phase: 'action-first-frame-candidates' }) + expect(actionRun.configureAction).toHaveBeenCalledOnce() + expect(actionRun.save).toHaveBeenCalledOnce() + }) + + it('lets nextStep publish only after the page explicitly approves review', async () => { + const run = createRun(createSnapshot('reviewing_animation')) + const completed = createSnapshot('completed', { status: 'completed' }) + vi.mocked(run.getActionReview).mockResolvedValue(actionReview(run.snapshot())) + vi.mocked(run.approveAction).mockResolvedValue({ + snapshot: completed, + character: { id: 'character-1' }, + characterId: 'character-1', + outfitId: 'outfit-1', + actionId: 'action-1', + } as unknown as PublishActionResult) + const { controller } = createFixture([run]) + + await expect(controller.nextStep(run.id)).resolves.toMatchObject({ phase: 'action-review' }) + expect(run.approveAction).not.toHaveBeenCalled() + await expect(controller.nextStep(run.id, { approve: true })).resolves.toEqual({ + phase: 'terminal', + snapshot: completed, + }) + }) + it('shares one in-flight recovery for concurrent route entries', async () => { const run = createRun(createSnapshot('selecting_character')) const pending = deferred() diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index 0028ac31..e03e266f 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -13,6 +13,7 @@ import type { CharacterCandidateBatch, ConfigureWorkflowActionInput, CreateWorkflowRunInput, + MediaReference, PublishActionResult, WorkflowRun, WorkflowRunService, @@ -34,9 +35,32 @@ export type WorkflowControllerSnapshot = | { phase: 'interrupted'; snapshot: WorkflowRunSnapshot } | { phase: 'terminal'; snapshot: WorkflowRunSnapshot } +/** + * nextStep 只接收当前卡片可能需要的用户决定。 + * Controller 会按真实 phase 解释字段,因此页面不需要复制工作流状态机。 + */ +export interface WorkflowNextStepInput { + selectedImageUrl?: string + action?: ConfigureWorkflowActionInput + approve?: boolean +} + export interface WorkflowController { /** 创建“角色 + 首个动作”Run,并开始生成四张角色候选。 */ startCharacter(input: CharacterRunInput): Promise + /** 先上传成功,再创建 Run 和正式 Character,避免留下上传失败的空运行。 */ + startCharacterFromUploadedTemplate( + input: CharacterRunInput, + file: File, + signal?: AbortSignal, + ): Promise> + /** 已有 Run 在角色生成开始前采用上传母版。 */ + continueWithUploadedTemplate( + runId: WorkflowRun['id'], + file: File, + description?: string | null, + signal?: AbortSignal, + ): Promise> /** 确认角色后仍留在同一个 Run,进入动作配置。 */ confirmCharacter( runId: WorkflowRun['id'], @@ -56,6 +80,14 @@ export interface WorkflowController { ): Promise /** 审核通过后把动作写回 Character。 */ approveAction(runId: WorkflowRun['id']): Promise + /** + * 通用推进入口。Quick Start 可以连续调用;Workflow Editor 每次点击调用一次。 + * 到达选图、动作配置或审核阶段且没有对应输入时,只返回当前可渲染状态。 + */ + nextStep( + runId: WorkflowRun['id'], + input?: WorkflowNextStepInput, + ): Promise /** 中断或继续 Run 时立即保存检查点。 */ interrupt(runId: WorkflowRun['id']): Promise continue(runId: WorkflowRun['id']): Promise @@ -73,10 +105,13 @@ export interface WorkflowController { export interface CreateWorkflowControllerOptions { /** Controller 只通过 Service 创建或恢复绑定后的 Run。 */ service: WorkflowRunService + /** Media Entity 的上传适配器;Controller 只接收不透明 MediaReference。 */ + uploadCharacterTemplate?: (file: File, signal?: AbortSignal) => Promise } export function createWorkflowController({ service, + uploadCharacterTemplate, }: CreateWorkflowControllerOptions): WorkflowController { // 一个页面会围绕同一个 Run 连续发出恢复、中断和确认命令。复用绑定实例可以让 // 中断立即改变正在等待异步结果的那份运行状态,避免旧实例稍后继续推进并覆盖检查点。 @@ -141,6 +176,52 @@ export function createWorkflowController({ return run.getActionReview() } + async function nextStep( + runId: WorkflowRun['id'], + input: WorkflowNextStepInput = {}, + ): Promise { + const run = await requireRun(runId) + const snapshot = run.snapshot() + if (snapshot.status === 'interrupted') { + run.continue() + await run.save() + return restoreRun(run) + } + if (snapshot.status !== 'active') return { phase: 'terminal', snapshot } + + const step = currentStep(snapshot) + if (step.type === 'character') { + if (step.phase === 'selecting_character' && input.selectedImageUrl) { + return { + phase: 'action-setup', + snapshot: await run.confirmCharacter(input.selectedImageUrl), + } + } + return restoreRun(run) + } + + if (step.phase === 'configuring_action') { + if (!input.action) return { phase: 'action-setup', snapshot } + run.configureAction(input.action) + await run.save() + return { + phase: 'action-first-frame-candidates', + ...(await run.resumeActionFirstFrameCandidates()), + } + } + if (step.phase === 'selecting_action_frame' && input.selectedImageUrl) { + return { + phase: 'action-review', + ...(await confirmActionFirstFrame(runId, input.selectedImageUrl)), + } + } + if (step.phase === 'reviewing_animation' && input.approve) { + const published = await run.approveAction() + return { phase: 'terminal', snapshot: published.snapshot } + } + return restoreRun(run) + } + return { async startCharacter(input) { const run = rememberRun(await service.create(input)) @@ -148,6 +229,27 @@ export function createWorkflowController({ if (!isCharacterBatch(result)) throw new Error('角色 Run 没有返回角色候选') return result }, + async startCharacterFromUploadedTemplate(input, file, signal) { + if (!uploadCharacterTemplate) throw new Error('角色母版上传服务尚未配置') + const referenceMedia = await uploadCharacterTemplate(file, signal) + const run = rememberRun(await service.create(input)) + return { + phase: 'action-setup', + snapshot: await run.acceptUploadedCharacterTemplate({ + referenceMedia, + description: input.characterPrompt, + }), + } + }, + async continueWithUploadedTemplate(runId, file, description, signal) { + if (!uploadCharacterTemplate) throw new Error('角色母版上传服务尚未配置') + const referenceMedia = await uploadCharacterTemplate(file, signal) + const run = await requireRun(runId) + return { + phase: 'action-setup', + snapshot: await run.acceptUploadedCharacterTemplate({ referenceMedia, description }), + } + }, async confirmCharacter(runId, selectedImageUrl) { const run = await requireRun(runId) const snapshot = await run.confirmCharacter(selectedImageUrl) @@ -169,6 +271,7 @@ export function createWorkflowController({ async approveAction(runId) { return (await requireRun(runId)).approveAction() }, + nextStep, async interrupt(runId) { const run = await requireRun(runId) run.interrupt() diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index aee6d4ab..32bedd62 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -5,4 +5,5 @@ export type { CreateWorkflowControllerOptions, WorkflowController, WorkflowControllerSnapshot, + WorkflowNextStepInput, } from './controller' From db31dd3a55fb06728edfb55bf4523667dde795b9 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:35:12 +0800 Subject: [PATCH 24/27] feat(workflow-controller): align character run commands --- frontend/src/entities/generation/index.ts | 3 + frontend/src/entities/index.ts | 46 +- frontend/src/entities/workflow-run/README.md | 51 +- frontend/src/entities/workflow-run/index.ts | 4 +- .../entities/workflow-run/model/constants.ts | 4 +- .../src/entities/workflow-run/model/index.ts | 2 + .../entities/workflow-run/model/selectors.ts | 22 + .../src/entities/workflow-run/model/types.ts | 16 +- .../entities/workflow-run/service/index.ts | 2 +- .../service/workflow-run-service.test.ts | 331 ++++++++++--- .../service/workflow-run-service.ts | 188 ++++---- .../store/workflow-run-store.test.ts | 56 ++- .../workflow-run/store/workflow-run-store.ts | 91 ++-- .../features/character-setup/index.test.ts | 10 + .../src/features/character-setup/index.ts | 4 +- .../features/workflow-controller/README.md | 64 +-- .../workflow-controller/controller.test.ts | 449 +++--------------- .../workflow-controller/controller.ts | 359 +++----------- .../src/features/workflow-controller/index.ts | 9 +- 19 files changed, 711 insertions(+), 1000 deletions(-) create mode 100644 frontend/src/entities/workflow-run/model/selectors.ts create mode 100644 frontend/src/features/character-setup/index.test.ts diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index 33474787..d6d716c2 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -24,6 +24,9 @@ export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' */ export type GenerationType = 'character_template' | 'first_frame' | 'complete_animation' +/** 完整动作进入 WorkflowRun 审核与发布前必须恰好包含的帧数。 */ +export const COMPLETE_ANIMATION_FRAME_COUNT = 32 + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 1c3bbc07..1a440023 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,17 +1,7 @@ -/** - * Entity 层的唯一公开入口。 - * - * Page 和 Feature 只从 `@/entities` 导入,不直接访问某个 Entity 的内部文件。 - * 这不是为了少写一段路径,而是为了稳定模块边界:内部文件可以重构, - * 但公开名称和依赖方向必须经过本文件明确审核。 - * - * 这里只暴露 Entity 级别的数据结构、后端端口契约以及必要的本地 Store 工厂。 - * 页面状态、路由、弹窗和按钮行为不属于 Entity,不应从此处导出。 - */ +/** Entity 层的唯一公开入口。外部模块不得绕过这里访问 Entity 内部文件。 */ -/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ -export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' -export { projectApis } from './project' +/* 项目:视角、朝向、精灵尺寸与画风等全局约束。 */ +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis } from './project' export type { CharacterPerspective, CreateProjectInput, @@ -21,7 +11,8 @@ export type { ProjectPageQuery, } from './project' -/* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ +/* 角色:角色、造型、动作和帧组成同一棵资产树。 */ +export { characterApis } from './character' export type { Action, ActionType, @@ -31,12 +22,11 @@ export type { Frame, Outfit, } from './character' -export { characterApis } from './character' -/* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' -/* 生成 —— 业务数据,不是「调用生成能力」;后端的 task 就是它,不另立实体 */ +/* 生成:后端 GenerationTask 的前端领域契约。 */ +export { COMPLETE_ANIMATION_FRAME_COUNT } from './generation' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -55,44 +45,46 @@ export type { TaskStatus, } from './generation' -/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' -/* - * 工作流 —— 记录“一次用户任务如何运行”。 - * 它不是角色/动作资产,也不是负责调后端的 WorkflowController。 - */ +/* WorkflowRun:一个 Character 的制作记录与可恢复执行能力。 */ export { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT, createWorkflowRunRepository, createWorkflowRunService, + findActionStepByActionId, isWorkflowRunSnapshot, + WORKFLOW_RUN_STORAGE_KEY, + WORKFLOW_RUN_STORAGE_VERSION, WORKFLOW_STEP_ORDERS, } from './workflow-run' export type { + AcceptUploadedCharacterTemplateInput, ActionFirstFrameCandidateBatch, ActionReviewResult, AppendWorkflowActionInput, BuiltinWorkflowRunSource, + CharacterCandidateBatch, + CharacterCandidateConfirmationApis, ConfigureWorkflowActionInput, + CreateWorkflowRunInput, CreateWorkflowRunRepositoryOptions, CreateWorkflowRunServiceOptions, - CreateWorkflowRunInput, - CharacterCandidateBatch, - CharacterCandidateConfirmationApis, PublishActionResult, WorkflowActionInput, WorkflowCharacterInput, + WorkflowCharacterOrigin, WorkflowGenerationRef, WorkflowGenerationRole, + WorkflowRevision, WorkflowRun, + WorkflowRunCharacterBinding, + WorkflowRunHandle, WorkflowRunKind, WorkflowRunRepository, - WorkflowRevision, WorkflowRunService, WorkflowRunSnapshot, - WorkflowRunCharacterBinding, WorkflowRunStatus, WorkflowStep, WorkflowStepPhase, diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index c1e9c7a1..ecd8ca03 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -6,7 +6,7 @@ WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模 ```text WorkflowDefinition(未来由 Workflow Editor 管理) - └─ WorkflowRun(一张已确认角色图的完整制作记录) + └─ WorkflowRun(一个 Character 的完整制作记录) └─ WorkflowRevision(从某张卡片重做形成的执行分支) ├─ Character Step(只出现一次) ├─ Action Step:待机 @@ -15,8 +15,9 @@ WorkflowDefinition(未来由 Workflow Editor 管理) ``` Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 -`ai/manual driver`。角色创建、首个动作和以后新增的动作都属于同一个 Run。这里的“同一张 -角色图”由后端确认后返回的 `characterId + outfitId` 标识,不依赖临时候选图 URL。 +`ai/manual driver`。一个 Character 只绑定一个 Run,首个动作和以后新增的动作都追加到这里。 +当前后端仍把动作放在默认 Outfit 下,但 Outfit 只是内部兼容结构,不参与 Run 定位,也不进入 +用户操作。 ## Step 与卡片 @@ -24,6 +25,10 @@ Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所 依次经历“生成四张候选、选择一张”;每个动作卡片分别经历“配置、生成四张首帧、选择首帧、 生成动画、审核、导出”。每个动作的输入和 GenerationTask 引用都按 Action Step ID 隔离。 +新 Run 只预建 Character Step。角色确认后立即保存并完成,用户可以直接退出;Quick Start +如果要连续生成首个动作,也必须调用与后续动作相同的 `appendAction`,不能依赖一张预埋的动作 +卡片。同一时间最多有一个活动 Action Step,当前动作完成或失败后才能追加下一个。 + ## Revision 解决什么 Quick Start 默认只有一个初始 Revision。Workflow Editor 从历史卡片重做时,在同一个 Run @@ -31,9 +36,13 @@ Quick Start 默认只有一个初始 Revision。Workflow Editor 从历史卡片 Revision 保持只读。异步请求返回时还要核对发起它的 Revision,避免旧分支的晚到结果污染 当前分支。 -WorkflowRevision 只描述执行分支,不是 GenerationTask,也不是 WorkflowDefinition 的定义 -版本。只有生成一张新的角色图才创建新 Run;给现有角色图新增动作,是在当前 Revision 追加 -Action Step,不创建 Run,也不拿 Revision 冒充动作列表。 +WorkflowRevision 只描述 Action Step 的执行分支,不是 GenerationTask,也不是 +WorkflowDefinition 的定义版本。重新生成角色图必须新建 Character 和 WorkflowRun,不能在原 +Run 内覆盖角色;给现有 Character 新增动作,则在当前 Revision 追加 Action Step。 + +`WorkflowRunSnapshot.version` 是另一件事:它只是持久化乐观锁。每次成功保存加一,旧页面用 +过期版本保存时会收到冲突错误,不能覆盖较新的 Run。代码和讲解中不要把这个数字与 +`WorkflowRevision` 混称为同一种 Revision。 ## 与后端生成执行的边界 @@ -47,13 +56,35 @@ WorkflowRun 只保存用户创作意图、当前卡片状态以及 `GenerationTa 后端以后即使更换提示词整理、模型选择或任务执行方案,也不需要迁移 WorkflowRun;刷新页面时, 前端只根据 GenerationTask ID 恢复任务。 +## Step ID 与 Action ID + +`stepId` 是前端 WorkflowRun 卡片 ID;正式 `actionId` 是后端保存动作后返回的资产 ID。两者不能 +共用。动作发布前 `WorkflowActionInput.actionId` 为 null;保存成功后,前端只把后端响应中的 +正式 ID 写回当前 Step。`findActionStepByActionId` 负责从正式动作反查当前 Revision 的卡片。 + ## 职责 - `model` 不依赖页面、localStorage 或 SSE。 - `store` 只负责异步持久化,不提供页面订阅。 -- `service.create` 只创建角色 Run;`appendAction` 根据 `projectId + characterId + outfitId` 找到 - 原 Run 并追加动作,找不到时明确失败,绝不偷偷创建第二个 Run。 -- 普通本地状态修改通过显式 `save()` 持久化;远程任务 ID 等恢复检查点会及时保存。 +- 当前 `LocalStorageWorkflowRunRepository` 是过渡实现;页面和业务层只依赖异步 Repository, + 后续换成 HTTP 实现不需要改调用方式。 +- `getByCharacter(projectId + characterId)` 精确定位 Character 的唯一 Run;Repository 保存时 + 拒绝第二条 Run 绑定同一个 Character。 +- `service.create` 只创建角色 Run;`appendAction` 根据 `projectId + characterId` 找到原 Run + 并追加动作,找不到时明确失败,绝不偷偷创建第二个 Run。 +- 绑定 Run 的状态修改由 Controller 在页面命令成功后保存;远程任务 ID 等恢复检查点由 Run + 自己及时保存。 - Generation SSE 继续由 Generation Entity 负责。 +- 上传母版用 `characterOrigin: uploaded` 表达,不伪造 GenerationTask;上传成功后立即创建并 + 绑定正式 Character/Outfit。 - 提示词整理、模型选择和内部执行过程由后端 Generation 模块负责。 -- 本地快照格式已升到 v6;旧的单动作快照不会被误水合为多动作结构。 +- 本地快照格式已升到 v8;旧的 Outfit 定位和前端 Action ID 结构不会被误水合。 + +## 动作生成验收 + +每次生成动作首帧和完整动画时,Service 都必须把已确认的角色母版放入 +`referenceMedia`。母版优先取当前 Outfit 的 `previewUrl`,没有造型预览时才回退到 Character +的 `referenceImageUrl`;两者都不存在就中止请求,不能发送空母版。 + +完整动画由后端负责生成和补帧。WorkflowRun 不修改帧数组,只在结果恰好包含 32 张有效帧时 +进入审核;少帧或多帧都会把当前 Action Step 标记为失败,禁止继续发布。 diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index ec24b432..09446ea5 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -3,6 +3,7 @@ export { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT, + findActionStepByActionId, WORKFLOW_STEP_ORDERS, } from './model' export type { @@ -17,6 +18,7 @@ export type { WorkflowGenerationRef, WorkflowGenerationRole, WorkflowRunKind, + WorkflowRun, WorkflowRevision, WorkflowRunSnapshot, WorkflowRunCharacterBinding, @@ -41,6 +43,6 @@ export type { CharacterCandidateConfirmationApis, CreateWorkflowRunServiceOptions, PublishActionResult, - WorkflowRun, + WorkflowRunHandle, WorkflowRunService, } from './service' diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts index 579d1cb7..db95f6c4 100644 --- a/frontend/src/entities/workflow-run/model/constants.ts +++ b/frontend/src/entities/workflow-run/model/constants.ts @@ -35,7 +35,7 @@ export const WORKFLOW_GENERATION_ROLES = [ export const CHARACTER_CANDIDATE_COUNT = 4 export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 -/** 内置流程的最小卡片顺序;后续新增动作会继续追加 action 卡片。 */ +/** 内置流程只预建角色卡片;角色确认后,所有动作都通过 appendAction 继续追加。 */ export const WORKFLOW_STEP_ORDERS = { - character_action: ['character', 'action'], + character_action: ['character'], } as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts index fb7ac4fa..290df361 100644 --- a/frontend/src/entities/workflow-run/model/index.ts +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -5,6 +5,7 @@ export { CHARACTER_CANDIDATE_COUNT, WORKFLOW_STEP_ORDERS, } from './constants' +export { findActionStepByActionId } from './selectors' export type { AcceptUploadedCharacterTemplateInput, AppendWorkflowActionInput, @@ -16,6 +17,7 @@ export type { WorkflowCharacterOrigin, WorkflowGenerationRef, WorkflowGenerationRole, + WorkflowRun, WorkflowRunKind, WorkflowRevision, WorkflowRunSnapshot, diff --git a/frontend/src/entities/workflow-run/model/selectors.ts b/frontend/src/entities/workflow-run/model/selectors.ts new file mode 100644 index 00000000..9b0c7fbb --- /dev/null +++ b/frontend/src/entities/workflow-run/model/selectors.ts @@ -0,0 +1,22 @@ +import type { WorkflowRunSnapshot, WorkflowStep } from './types' + +/** + * 用后端正式 Action ID 定位当前 Revision 中的动作卡片。 + * Playtest 和资产页面不需要理解 actionInputs 以 Step ID 为 key 的内部结构。 + */ +export function findActionStepByActionId( + run: WorkflowRunSnapshot, + actionId: string, +): WorkflowStep | null { + const normalizedActionId = actionId.trim() + if (!normalizedActionId) return null + + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision) return null + const entry = Object.entries(revision.actionInputs).find( + ([, input]) => input.actionId === normalizedActionId, + ) + if (!entry) return null + + return revision.steps.find((step) => step.id === entry[0] && step.type === 'action') ?? null +} diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index c039def2..a2c67bc3 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -64,6 +64,7 @@ export interface WorkflowRevision { /** 生成候选确认和用户上传是两条不同来源,不能用假的 GenerationTask 混在一起。 */ characterOrigin: WorkflowCharacterOrigin | null characterId: string | null + /** 当前后端 character_data 仍需要默认 Outfit;它只是兼容字段,不参与 Run 的业务身份。 */ outfitId: string | null characterSelectedAt: string | null /** 每个动作卡片保存自己的输入;key 是当前 Revision 内的 Action Step ID。 */ @@ -83,10 +84,14 @@ export interface WorkflowCharacterInput { /** 用户的角色创作意图;后端生成模块负责转换为实际模型输入。 */ prompt: string referenceMedia: readonly MediaReference[] + /** 生成角色候选时必须沿用项目的精灵尺寸,刷新恢复后也不能丢失。 */ + spriteWidth: number + spriteHeight: number } export interface WorkflowActionInput { - id: string + /** 正式动作资产 ID 只接受后端保存结果;动作尚未发布时为 null。 */ + actionId: string | null name: string type: ActionType /** 用户的动作描述;不是直接发送给模型供应商的最终提示词。 */ @@ -118,11 +123,16 @@ export interface WorkflowRunSnapshot { updatedAt: string } +/** 页面读取的 WorkflowRun 数据就是可持久化快照;命令方法由 WorkflowRunHandle 提供。 */ +export type WorkflowRun = WorkflowRunSnapshot + /** 新建角色任务;新增动作必须追加到这个角色已经存在的 Run。 */ export interface CreateWorkflowRunInput { projectId: string characterPrompt: string referenceMedia?: readonly MediaReference[] + /** 未传时兼容旧入口使用 256;真实页面必须传项目精灵尺寸。 */ + spriteSize?: { width: number; height: number } } /** 配置当前活动的动作卡片。 */ @@ -133,17 +143,15 @@ export interface ConfigureWorkflowActionInput { fps: number } -/** 按角色图定位原 Run,并在其中追加动作卡片。 */ +/** 按 Character 定位原 Run,并在其中追加动作卡片。 */ export interface AppendWorkflowActionInput extends ConfigureWorkflowActionInput { projectId: string characterId: string - outfitId: string } export interface WorkflowRunCharacterBinding { projectId: string characterId: string - outfitId: string } export interface AcceptUploadedCharacterTemplateInput { diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts index a0ea6a99..0e2387ab 100644 --- a/frontend/src/entities/workflow-run/service/index.ts +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -13,6 +13,6 @@ export type { CharacterCandidateConfirmationApis, CreateWorkflowRunServiceOptions, PublishActionResult, - WorkflowRun, + WorkflowRunHandle, WorkflowRunService, } from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts index 0a2152e0..af51c278 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import type { Character, CharacterApis } from '../../character' -import type { Generation, GenerationApis, GenerationEvent, GenerationInput } from '../../generation' +import { + COMPLETE_ANIMATION_FRAME_COUNT, + type Generation, + type GenerationApis, + type GenerationEvent, + type GenerationInput, +} from '../../generation' import type { MediaReference } from '../../media' -import type { WorkflowRunSnapshot } from '../model' +import { findActionStepByActionId, type WorkflowRunSnapshot } from '../model' import { createWorkflowRunRepository } from '../store' import { createWorkflowRunService, @@ -49,7 +55,9 @@ function createGenerationApis() { ? { type: 'first_frame' as const, image: { url: `first-frame-${id}.png` } } : { type: 'complete_animation' as const, - frames: [{ url: 'frame-1.png' }, { url: 'frame-2.png' }], + frames: Array.from({ length: COMPLETE_ANIMATION_FRAME_COUNT }, (_, index) => ({ + url: `frame-${index + 1}.png`, + })), } const task: Generation = { id, @@ -78,6 +86,7 @@ function createGenerationApis() { function createFixture() { let id = 0 + let backendActionId = 0 let timestamp = 0 const repository = createWorkflowRunRepository({ storage: null }) const generation = createGenerationApis() @@ -92,7 +101,15 @@ function createFixture() { })), create: vi.fn(async () => structuredClone(character)), update: vi.fn(async (next) => { + const existingActionIds = new Set( + character.outfits.flatMap((outfit) => outfit.actions.map((action) => action.id)), + ) character = structuredClone(next) + for (const outfit of character.outfits) { + for (const action of outfit.actions) { + if (!existingActionIds.has(action.id)) action.id = `backend-action-${++backendActionId}` + } + } return structuredClone(character) }), remove: vi.fn(async () => undefined), @@ -122,14 +139,14 @@ function currentRevisionSnapshot(snapshot: WorkflowRunSnapshot) { } describe('WorkflowRun instance', () => { - it('uses one run and two card-aligned steps for character plus first action', async () => { + it('completes after character selection and appends the first action to the same run', async () => { const fixture = createFixture() const run = await fixture.service.create({ projectId: 'project-1', characterPrompt: '一位像素风守夜人', }) const runId = run.id - expect(currentSteps(run.snapshot()).map((step) => step.type)).toEqual(['character', 'action']) + expect(currentSteps(run.snapshot()).map((step) => step.type)).toEqual(['character']) const characters = (await run.start()) as CharacterCandidateBatch expect(characters.candidates).toHaveLength(4) @@ -141,33 +158,31 @@ describe('WorkflowRun instance', () => { expect(JSON.stringify(await fixture.repository.get(runId))).not.toContain('candidate-1.png') await run.confirmCharacter('candidate-2.png') - expect(run.snapshot()).toMatchObject({ id: runId, status: 'active' }) + expect(run.snapshot()).toMatchObject({ id: runId, status: 'completed' }) expect(currentRevisionSnapshot(run.snapshot())).toMatchObject({ characterId: 'character-1', outfitId: 'outfit-1', }) - expect(currentSteps(run.snapshot())[1]).toMatchObject({ - type: 'action', - status: 'active', - phase: 'configuring_action', - }) + expect(currentSteps(run.snapshot())).toHaveLength(1) - run.configureAction({ + const actionRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', actionName: '向前行走', actionType: 'walk', actionPrompt: '轻快地向前行走', fps: 12, }) - const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch + const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch expect(firstFrames.candidates).toHaveLength(4) expect(firstFrames.snapshot.id).toBe(runId) expect(currentSteps(firstFrames.snapshot)[1]).toMatchObject({ phase: 'selecting_action_frame', }) - await run.confirmActionFirstFrame(firstFrames.candidates[1]!) - expect(currentSteps(run.snapshot())[1]).toMatchObject({ phase: 'reviewing_animation' }) - const published = await run.approveAction() + await actionRun.confirmActionFirstFrame(firstFrames.candidates[1]!) + expect(currentSteps(actionRun.snapshot())[1]).toMatchObject({ phase: 'reviewing_animation' }) + const published = await actionRun.approveAction() expect(published.snapshot).toMatchObject({ id: runId, status: 'completed' }) expect(currentSteps(published.snapshot)).toEqual( @@ -176,30 +191,121 @@ describe('WorkflowRun instance', () => { expect.objectContaining({ type: 'action', status: 'passed', phase: 'completed' }), ]), ) - expect(published.character.outfits[0]?.actions[0]).toMatchObject({ + const publishedAction = published.character.outfits[0]?.actions[0] + expect(publishedAction).toMatchObject({ + id: 'backend-action-1', name: '向前行走', type: 'walk', loop: true, fps: 12, - frameCount: 2, - frames: [ - expect.objectContaining({ index: 0, imageUrl: 'frame-1.png' }), - expect.objectContaining({ index: 1, imageUrl: 'frame-2.png' }), - ], + frameCount: COMPLETE_ANIMATION_FRAME_COUNT, }) + expect(publishedAction?.frames).toHaveLength(COMPLETE_ANIMATION_FRAME_COUNT) + expect(publishedAction?.frames[0]).toMatchObject({ index: 0, imageUrl: 'frame-1.png' }) + expect(publishedAction?.frames.at(-1)).toMatchObject({ + index: COMPLETE_ANIMATION_FRAME_COUNT - 1, + imageUrl: `frame-${COMPLETE_ANIMATION_FRAME_COUNT}.png`, + }) + expect(published.actionId).toBe('backend-action-1') + expect( + currentRevisionSnapshot(published.snapshot).actionInputs[ + currentSteps(published.snapshot)[1]!.id + ], + ).toMatchObject({ actionId: 'backend-action-1' }) + expect(published.actionId).not.toBe(currentSteps(published.snapshot)[1]!.id) + expect(findActionStepByActionId(published.snapshot, published.actionId)?.id).toBe( + currentSteps(published.snapshot)[1]!.id, + ) + const actionGenerationInputs = fixture.generation.create.mock.calls + .map(([input]) => input) + .filter((input) => input.type === 'first_frame' || input.type === 'complete_animation') + expect(actionGenerationInputs).toHaveLength(5) + expect( + actionGenerationInputs.every((input) => + input.referenceMedia.includes('candidate-2.png' as MediaReference), + ), + ).toBe(true) expect(fixture.generation.create).toHaveBeenCalledTimes(6) expect(fixture.confirmSelection).toHaveBeenCalledTimes(1) }) + it('rejects a completed animation task that does not contain exactly 32 frames', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + projectId: 'project-1', + characterPrompt: '一位像素风守夜人', + }) + const characters = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characters.candidates[0]!) + const actionRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '挥手', + actionType: 'custom', + fps: 12, + }) + const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch + const underfilled: Generation<'complete_animation'> = { + id: 'generation-underfilled', + projectId: 'project-1', + type: 'complete_animation', + status: 'completed', + result: { + type: 'complete_animation', + frames: Array.from({ length: 7 }, (_, index) => ({ + url: `underfilled-${index}.png`, + })), + }, + error: null, + } + fixture.generation.apis.create = vi.fn(async () => { + fixture.generation.tasks.set(underfilled.id, underfilled) + return underfilled + }) as GenerationApis['create'] + + await expect(actionRun.confirmActionFirstFrame(firstFrames.candidates[0]!)).rejects.toThrow( + '动作生成应返回 32 帧,实际返回 7 帧', + ) + expect(actionRun.snapshot()).toMatchObject({ status: 'failed' }) + expect(currentSteps(actionRun.snapshot())[1]).toMatchObject({ + status: 'failed', + error: '动作生成应返回 32 帧,实际返回 7 帧', + }) + }) + + it('marks the Action Step failed when no confirmed master can be loaded', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ + projectId: 'project-1', + characterPrompt: '一位像素风守夜人', + }) + const characters = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characters.candidates[0]!) + const actionRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '挥手', + actionType: 'custom', + fps: 12, + }) + const withoutMaster = createCharacter() + withoutMaster.referenceImageUrl = null + withoutMaster.outfits[0]!.previewUrl = null + vi.mocked(fixture.characterApis.get).mockResolvedValue(withoutMaster) + + await expect(actionRun.start()).rejects.toThrow('动作生成需要已确认的角色母版') + + expect(actionRun.snapshot().status).toBe('failed') + expect(currentSteps(actionRun.snapshot())[1]).toMatchObject({ + status: 'failed', + error: '动作生成需要已确认的角色母版', + }) + await expect(fixture.repository.get(actionRun.id)).resolves.toMatchObject({ status: 'failed' }) + }) + it('binds operations to the run instance and exposes character-scoped action append', async () => { const { service } = createFixture() - expect(Object.keys(service).sort()).toEqual([ - 'appendAction', - 'create', - 'get', - 'getByCharacterOutfit', - 'listByCharacter', - ]) + expect(Object.keys(service).sort()).toEqual(['appendAction', 'create', 'get', 'getByCharacter']) const created = await service.create({ projectId: 'project-1', characterPrompt: '角色', @@ -208,10 +314,9 @@ describe('WorkflowRun instance', () => { expect(restored?.id).toBe(created.id) expect(restored?.snapshot()).toEqual(created.snapshot()) await expect( - service.getByCharacterOutfit({ + service.getByCharacter({ projectId: 'project-1', characterId: 'missing-character', - outfitId: 'missing-outfit', }), ).resolves.toBeNull() }) @@ -224,15 +329,20 @@ describe('WorkflowRun instance', () => { }) const characterCandidates = (await run.start()) as CharacterCandidateBatch await run.confirmCharacter(characterCandidates.candidates[0]!) - run.configureAction({ actionName: '待机', actionType: 'idle', fps: 8 }) - const idleFrames = (await run.start()) as ActionFirstFrameCandidateBatch - await run.confirmActionFirstFrame(idleFrames.candidates[0]!) - await run.approveAction() + const idleRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '待机', + actionType: 'idle', + fps: 8, + }) + const idleFrames = (await idleRun.start()) as ActionFirstFrameCandidateBatch + await idleRun.confirmActionFirstFrame(idleFrames.candidates[0]!) + await idleRun.approveAction() const appended = await fixture.service.appendAction({ projectId: 'project-1', characterId: 'character-1', - outfitId: 'outfit-1', actionName: '行走', actionType: 'walk', fps: 12, @@ -258,6 +368,48 @@ describe('WorkflowRun instance', () => { ]) }) + it('allows the next action only after the current action has failed', async () => { + const fixture = createFixture() + const run = await fixture.service.create({ projectId: 'project-1', characterPrompt: '角色' }) + const characters = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characters.candidates[0]!) + const failedRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '失败动作', + actionType: 'custom', + fps: 12, + }) + + await expect( + fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '过早追加', + actionType: 'custom', + fps: 12, + }), + ).rejects.toThrow('当前动作结束后才能追加新动作') + + fixture.generation.apis.create = vi.fn(async () => { + throw new Error('生成失败') + }) as GenerationApis['create'] + await expect(failedRun.start()).rejects.toThrow('生成失败') + + const nextRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '下一个动作', + actionType: 'idle', + fps: 8, + }) + expect(currentSteps(nextRun.snapshot()).map((step) => step.status)).toEqual([ + 'passed', + 'failed', + 'active', + ]) + }) + it('turns an uploaded template into a formal character binding without a fake generation', async () => { const fixture = createFixture() const run = await fixture.service.create({ @@ -282,11 +434,8 @@ describe('WorkflowRun instance', () => { phase: 'completed', generations: [], }) - expect(revision.steps[1]).toMatchObject({ - type: 'action', - status: 'active', - phase: 'configuring_action', - }) + expect(revision.steps).toHaveLength(1) + expect(accepted.status).toBe('completed') expect(fixture.characterApis.create).toHaveBeenCalledWith( expect.objectContaining({ referenceImageUrl: 'media://uploaded-template' }), ) @@ -318,13 +467,19 @@ describe('WorkflowRun instance', () => { }) const characterCandidates = (await run.start()) as CharacterCandidateBatch await run.confirmCharacter(characterCandidates.candidates[0]!) - run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) - await run.start() + const actionRun = await service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + await actionRun.start() - const before = run.snapshot() + const before = actionRun.snapshot() const parent = before.revisions[0]! const actionStep = currentSteps(before)[1]! - const restarted = run.restartFromStep(actionStep.id) + const restarted = actionRun.restartFromStep(actionStep.id) const next = restarted.revisions[1]! expect(restarted.id).toBe(before.id) @@ -348,12 +503,37 @@ describe('WorkflowRun instance', () => { expect(restarted.revisions[0]).toEqual(parent) }) + it('requires a new Character and WorkflowRun when regenerating the character image', async () => { + const { service } = createFixture() + const run = await service.create({ projectId: 'project-1', characterPrompt: '角色' }) + + expect(() => run.restartFromStep(currentSteps(run.snapshot())[0]!.id)).toThrow( + '重新生成角色必须创建新的 Character 和 WorkflowRun', + ) + expect(run.snapshot().revisions).toHaveLength(1) + }) + it('does not let an old revision asynchronous result mutate the new revision', async () => { const fixture = createFixture() - const running: Generation<'character_template'> = { + const run = await fixture.service.create({ + projectId: 'project-1', + characterPrompt: '异步角色', + }) + const characters = (await run.start()) as CharacterCandidateBatch + await run.confirmCharacter(characters.candidates[0]!) + const actionRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch + + const running: Generation<'complete_animation'> = { id: 'generation-running', projectId: 'project-1', - type: 'character_template', + type: 'complete_animation', status: 'running', result: null, error: null, @@ -362,37 +542,40 @@ describe('WorkflowRun instance', () => { throw new Error('生成订阅尚未建立') } fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] - fixture.generation.apis.get = vi.fn(async () => running) + fixture.generation.apis.get = vi.fn(async (_projectId, taskId) => { + if (taskId === running.id) return running + const generation = fixture.generation.tasks.get(taskId) + if (!generation) throw new Error(`GenerationTask 不存在:${taskId}`) + return structuredClone(generation) + }) fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { emit = onEvent return () => undefined }) - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '异步角色', - }) - const pending = run.start() + const pending = actionRun.confirmActionFirstFrame(firstFrames.candidates[0]!) await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) - const oldStep = currentSteps(run.snapshot())[0]! - run.restartFromStep(oldStep.id) + const oldStep = currentSteps(actionRun.snapshot())[1]! + actionRun.restartFromStep(oldStep.id) emit({ taskId: running.id, - type: 'character_template', + type: 'complete_animation', status: 'completed', result: { - type: 'character_template', - images: [1, 2, 3, 4].map((index) => ({ url: `late-${index}.png` })), + type: 'complete_animation', + frames: Array.from({ length: COMPLETE_ANIMATION_FRAME_COUNT }, (_, index) => ({ + url: `late-frame-${index}.png`, + })), }, error: null, }) await expect(pending).rejects.toThrow('已切换到新的 Revision') - const snapshot = run.snapshot() + const snapshot = actionRun.snapshot() expect(snapshot.revisions).toHaveLength(2) - expect(currentSteps(snapshot)[0]).toMatchObject({ + expect(currentSteps(snapshot)[1]).toMatchObject({ status: 'active', - phase: 'generating_character_candidates', + phase: 'configuring_action', generations: [], }) }) @@ -408,9 +591,15 @@ describe('WorkflowRun instance', () => { expect(confirmSelection).not.toHaveBeenCalled() await run.confirmCharacter('candidate-1.png') - run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) - await run.start() - await expect(run.confirmActionFirstFrame('foreign.png')).rejects.toThrow( + const actionRun = await service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + await actionRun.start() + await expect(actionRun.confirmActionFirstFrame('foreign.png')).rejects.toThrow( '不属于当前动作首帧任务', ) expect(generation.create).toHaveBeenCalledTimes(5) @@ -424,11 +613,17 @@ describe('WorkflowRun instance', () => { }) const characters = (await run.start()) as CharacterCandidateBatch await run.confirmCharacter(characters.candidates[0]!) - run.configureAction({ actionName: '行走', actionType: 'walk', fps: 12 }) - const firstFrames = (await run.start()) as ActionFirstFrameCandidateBatch - await run.confirmActionFirstFrame(firstFrames.candidates[0]!) + const actionRun = await fixture.service.appendAction({ + projectId: 'project-1', + characterId: 'character-1', + actionName: '行走', + actionType: 'walk', + fps: 12, + }) + const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch + await actionRun.confirmActionFirstFrame(firstFrames.candidates[0]!) - const restored = (await fixture.service.get(run.id))! + const restored = (await fixture.service.get(actionRun.id))! const resumed = await restored.resumeAction() expect(currentSteps(resumed)[1]).toMatchObject({ phase: 'reviewing_animation' }) expect(fixture.generation.create).toHaveBeenCalledTimes(6) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 753421b7..82c4a8bc 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -1,13 +1,15 @@ /** WorkflowRun 的运行实例与用例入口。 */ import type { Action, Character, CharacterApis, Frame } from '../../character' -import type { - CharacterTemplateGenerationResult, - CompleteAnimationGenerationResult, - Generation, - GenerationApis, - GenerationEvent, +import { + COMPLETE_ANIMATION_FRAME_COUNT, + type CharacterTemplateGenerationResult, + type CompleteAnimationGenerationResult, + type Generation, + type GenerationApis, + type GenerationEvent, } from '../../generation' +import type { MediaReference } from '../../media' import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' import type { AcceptUploadedCharacterTemplateInput, @@ -60,7 +62,6 @@ export interface PublishActionResult { snapshot: WorkflowRunSnapshot character: Character characterId: string - outfitId: string actionId: string } @@ -68,7 +69,7 @@ export interface PublishActionResult { * 绑定具体 Run 的运行对象。页面持有这个对象即可,不再同时传递 Service 和 runId。 * `snapshot()` 返回可渲染数据;`save()` 是显式持久化边界。 */ -export interface WorkflowRun { +export interface WorkflowRunHandle { readonly id: string snapshot(): WorkflowRunSnapshot save(): Promise @@ -79,6 +80,7 @@ export interface WorkflowRun { start(): Promise resumeCharacterCandidates(): Promise confirmCharacter(selectedImageUrl: string): Promise + /** 上传成功后立即创建正式 Character/Outfit,并把 ID 写入当前 Run。 */ acceptUploadedCharacterTemplate( input: AcceptUploadedCharacterTemplateInput, ): Promise @@ -94,12 +96,11 @@ export interface WorkflowRun { /** Service 只负责创建或恢复运行实例。 */ export interface WorkflowRunService { - create(input: CreateWorkflowRunInput): Promise - get(runId: WorkflowRunSnapshot['id']): Promise - getByCharacterOutfit(binding: WorkflowRunCharacterBinding): Promise - listByCharacter(characterId: string): Promise - /** 根据角色图找到原 Run,并追加动作;找不到时失败,绝不偷偷创建第二个 Run。 */ - appendAction(input: AppendWorkflowActionInput): Promise + create(input: CreateWorkflowRunInput): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + getByCharacter(binding: WorkflowRunCharacterBinding): Promise + /** 根据 Character 找到原 Run 并追加动作;找不到时失败,绝不偷偷创建第二个 Run。 */ + appendAction(input: AppendWorkflowActionInput): Promise } export interface CreateWorkflowRunServiceOptions { @@ -121,7 +122,7 @@ export function createWorkflowRunService( const createId = options.createId ?? createRandomId const now = options.now ?? (() => new Date().toISOString()) - function bind(initial: WorkflowRunSnapshot): WorkflowRun { + function bind(initial: WorkflowRunSnapshot): WorkflowRunHandle { let state = structuredClone(initial) const current = (): WorkflowRunSnapshot => structuredClone(state) @@ -175,6 +176,8 @@ export function createWorkflowRunService( projectId: state.projectId, prompt: input.prompt, referenceMedia: input.referenceMedia, + spriteWidth: input.spriteWidth, + spriteHeight: input.spriteHeight, }) generationId = generation.id assertCurrentRevision(state, revisionId) @@ -224,14 +227,11 @@ export function createWorkflowRunService( } const action = requireActionInput(state) const { characterId, outfitId } = requireCharacterBinding(state) - const character = await options.characterApis.get(characterId) - assertCurrentRevision(state, revisionId) - const outfit = character.outfits.find((item) => item.id === outfitId) - if (!outfit || !character.referenceImageUrl) { - throw new Error('动作生成需要已确认的角色母版') - } try { + // 母版读取也属于本次生成动作。读取失败时必须把 Action Step 标记为 failed, + // 否则刷新页面会把它误当成仍在生成并一直尝试恢复一个不存在的任务。 + const masterReference = await loadMasterReference(characterId, outfitId, revisionId) while ( generationIdsFor(requireActiveActionStep(state), 'action_frame_candidate').length < ACTION_FIRST_FRAME_CANDIDATE_COUNT @@ -243,7 +243,7 @@ export function createWorkflowRunService( outfitId, actionType: action.type, prompt: action.prompt, - referenceMedia: [], + referenceMedia: [masterReference], }) assertCurrentRevision(state, revisionId) await checkpoint((draft) => { @@ -287,7 +287,7 @@ export function createWorkflowRunService( return { snapshot: current(), candidateTaskIds: taskIds, candidates } } - const run: WorkflowRun = { + const run: WorkflowRunHandle = { id: state.id, snapshot: current, save: persist, @@ -307,26 +307,27 @@ export function createWorkflowRunService( const parent = currentRevision(state) const target = parent.steps.find((step) => step.id === stepId) if (!target) throw new Error('重做目标不属于当前 Revision') + if (target.type === 'character') { + throw new Error('重新生成角色必须创建新的 Character 和 WorkflowRun') + } if ( - target.type === 'action' && - parent.steps.some((step) => step !== target && step.status !== 'passed') + parent.steps.some( + (step) => step !== target && !['passed', 'failed'].includes(step.status), + ) ) { throw new Error('当前还有其他未完成卡片,不能重做这个动作') } const createdAt = now() const actionInputs: WorkflowRevision['actionInputs'] = {} - const steps = - target.type === 'character' - ? [createRestartedStep(target, true, createId), createActionStep(createId, false)] - : parent.steps.map((source) => { - const next = createRestartedStep(source, source === target, createId) - if (source.type === 'action') { - const input = parent.actionInputs[source.id] - if (input) actionInputs[next.id] = structuredClone(input) - } - return next - }) + const steps = parent.steps.map((source) => { + const next = createRestartedStep(source, source === target, createId) + if (source.type === 'action') { + const input = parent.actionInputs[source.id] + if (input) actionInputs[next.id] = structuredClone(input) + } + return next + }) const revision: WorkflowRevision = { id: createId(), parentRevisionId: parent.id, @@ -340,12 +341,6 @@ export function createWorkflowRunService( actionInputs, createdAt, } - if (target.type === 'character') { - revision.characterId = null - revision.outfitId = null - revision.characterSelectedAt = null - revision.characterOrigin = null - } return mutate((draft) => { draft.revisions.push(revision) draft.currentRevisionId = revision.id @@ -386,12 +381,7 @@ export function createWorkflowRunService( revision.characterId = confirmed.character.id revision.outfitId = confirmed.outfitId revision.characterSelectedAt = now() - const actionStep = currentRevision(draft).steps.find( - (item) => item.type === 'action' && item.status === 'locked', - ) - if (!actionStep) throw new Error('WorkflowRun 缺少首个动作卡片') - actionStep.status = 'active' - actionStep.phase = 'configuring_action' + draft.status = 'completed' }) }, async acceptUploadedCharacterTemplate(input) { @@ -408,7 +398,7 @@ export function createWorkflowRunService( } const referenceImageUrl = String(input.referenceMedia).trim() if (!referenceImageUrl) throw new TypeError('上传母版引用不能为空') - const description = input.description?.trim() || revision.characterInput?.prompt || null + const description = input.description?.trim() || revision.characterInput?.prompt || '' let character = await options.characterApis.create({ projectId: state.projectId, @@ -449,12 +439,7 @@ export function createWorkflowRunService( const draftCharacterStep = requireStep(draft, 'character') draftCharacterStep.status = 'passed' draftCharacterStep.phase = 'completed' - const actionStep = draftRevision.steps.find( - (step) => step.type === 'action' && step.status === 'locked', - ) - if (!actionStep) throw new Error('WorkflowRun 缺少首个动作卡片') - actionStep.status = 'active' - actionStep.phase = 'configuring_action' + draft.status = 'completed' }) }, configureAction(input) { @@ -466,7 +451,7 @@ export function createWorkflowRunService( const actionStep = requireActiveActionStep(draft) const previous = currentRevision(draft).actionInputs[actionStep.id] currentRevision(draft).actionInputs[actionStep.id] = { - id: previous?.id ?? createId(), + actionId: previous?.actionId ?? null, name: input.actionName.trim(), type: input.actionType, prompt: input.actionPrompt?.trim() || null, @@ -476,16 +461,16 @@ export function createWorkflowRunService( }) }, appendAction(input) { - if (state.status !== 'completed') { - throw new Error('只有已完成当前动作的 WorkflowRun 才能追加新动作') + if (state.status !== 'completed' && state.status !== 'failed') { + throw new Error('当前动作结束后才能追加新动作') } requireCharacterBinding(state) validateActionInput(input) return mutate((draft) => { const revision = currentRevision(draft) - const actionStep = createActionStep(createId, true) + const actionStep = createActionStep(createId) revision.steps.push(actionStep) - revision.actionInputs[actionStep.id] = createActionInput(input, createId) + revision.actionInputs[actionStep.id] = createActionInput(input) draft.status = 'active' }) }, @@ -505,6 +490,7 @@ export function createWorkflowRunService( const action = requireActionInput(state) const { characterId, outfitId } = requireCharacterBinding(state) try { + const masterReference = await loadMasterReference(characterId, outfitId, revisionId) const generation = await options.generationApis.create({ type: 'complete_animation', projectId: state.projectId, @@ -513,7 +499,7 @@ export function createWorkflowRunService( actionType: action.type, firstFrameUrl: selectedImageUrl, prompt: action.prompt, - referenceMedia: [], + referenceMedia: [masterReference], }) assertCurrentRevision(state, revisionId) await checkpoint((draft) => { @@ -578,15 +564,17 @@ export function createWorkflowRunService( const { characterId, outfitId } = requireCharacterBinding(state) const character = await options.characterApis.get(characterId) const outfit = character.outfits.find((item) => item.id === outfitId) - if (!outfit) throw new Error('动作所属造型不存在') + if (!outfit) throw new Error('动作所属默认造型不存在') + const previousActionIds = new Set(outfit.actions.map((item) => item.id)) + const requestedActionId = actionInput.actionId ?? createId() const action: Action = { - id: actionInput.id, + id: requestedActionId, outfitId, name: actionInput.name, type: actionInput.type, loop: ['idle', 'walk', 'run'].includes(actionInput.type), fps: actionInput.fps, - frameCount: review.frames.length, + frameCount: COMPLETE_ANIMATION_FRAME_COUNT, frames: review.frames.map((frame, index) => ({ index, imageUrl: frame.imageUrl, @@ -600,7 +588,7 @@ export function createWorkflowRunService( ? { ...item, actions: [ - ...item.actions.filter((existing) => existing.id !== actionInput.id), + ...item.actions.filter((existing) => existing.id !== requestedActionId), action, ], } @@ -608,9 +596,17 @@ export function createWorkflowRunService( ), }) assertCurrentRevision(state, revisionId) + const savedOutfit = savedCharacter.outfits.find((item) => item.id === outfitId) + const persistedAction = + savedOutfit?.actions.find((item) => item.id === actionInput.actionId) ?? + savedOutfit?.actions.find((item) => !previousActionIds.has(item.id)) + if (!persistedAction) throw new Error('角色服务没有返回已保存动作的正式 ID') await checkpoint((draft) => { assertCurrentRevision(draft, revisionId) const actionStep = requireActiveActionStep(draft) + const draftActionInput = currentRevision(draft).actionInputs[actionStep.id] + if (!draftActionInput) throw new Error('WorkflowRun 缺少动作输入') + draftActionInput.actionId = persistedAction.id actionStep.phase = 'completed' actionStep.status = 'passed' draft.status = 'completed' @@ -619,11 +615,28 @@ export function createWorkflowRunService( snapshot: current(), character: savedCharacter, characterId, - outfitId, - actionId: actionInput.id, + actionId: persistedAction.id, } }, } + + /** + * 动作生成必须把用户确认后的角色母版显式传给 Generation API。Character 顶层参考图是 + * 兼容回退;正式造型预览优先。这里返回 MediaReference 只是跨前端接口传递同一 URL, + * 不重新上传文件,也不把母版内容写进 WorkflowRun。 + */ + async function loadMasterReference( + characterId: string, + outfitId: string, + revisionId: string, + ): Promise { + const character = await options.characterApis.get(characterId) + assertCurrentRevision(state, revisionId) + const outfit = character.outfits.find((item) => item.id === outfitId) + const reference = outfit?.previewUrl ?? character.referenceImageUrl + if (!outfit || !reference?.trim()) throw new Error('动作生成需要已确认的角色母版') + return reference as MediaReference + } return run } @@ -636,18 +649,15 @@ export function createWorkflowRunService( const state = await options.repository.get(runId) return state ? bind(state) : null }, - async getByCharacterOutfit(binding) { - const state = await options.repository.getByCharacterOutfit(binding) + async getByCharacter(binding) { + const state = await options.repository.getByCharacter(binding) return state ? bind(state) : null }, - listByCharacter(characterId) { - return options.repository.listByCharacter(characterId) - }, async appendAction(input) { validateCharacterBinding(input) validateActionInput(input) - const state = await options.repository.getByCharacterOutfit(input) - if (!state) throw new Error('该角色图没有可追加动作的 WorkflowRun') + const state = await options.repository.getByCharacter(input) + if (!state) throw new Error('该 Character 没有可追加动作的 WorkflowRun') const run = bind(state) run.appendAction(input) await run.save() @@ -674,7 +684,7 @@ function createInitialSnapshot( generations: [], error: null, }) - const steps = [characterStep(), createActionStep(createId, false)] + const steps = [characterStep()] const initialRevisionId = createId() return { @@ -693,6 +703,8 @@ function createInitialSnapshot( characterInput: { prompt: input.characterPrompt.trim(), referenceMedia: input.referenceMedia ?? [], + spriteWidth: input.spriteSize?.width ?? 256, + spriteHeight: input.spriteSize?.height ?? 256, }, characterOrigin: null, characterId: null, @@ -713,8 +725,8 @@ function validateActionInput(input: ConfigureWorkflowActionInput): void { } function validateCharacterBinding(input: AppendWorkflowActionInput): void { - if (!input.projectId.trim() || !input.characterId.trim() || !input.outfitId.trim()) { - throw new TypeError('追加动作必须绑定项目、角色和造型') + if (!input.projectId.trim() || !input.characterId.trim()) { + throw new TypeError('追加动作必须绑定项目和角色') } } @@ -765,22 +777,22 @@ function createRestartedStep( return step } -function createActionStep(createId: () => string, active: boolean): WorkflowStep { +function createActionStep(createId: () => string): WorkflowStep { const id = createId() return { id, nodeId: `builtin-action:${id}`, type: 'action', - status: active ? 'active' : 'locked', - phase: active ? 'generating_action_candidates' : 'configuring_action', + status: 'active', + phase: 'generating_action_candidates', generations: [], error: null, } } -function createActionInput(input: ConfigureWorkflowActionInput, createId: () => string) { +function createActionInput(input: ConfigureWorkflowActionInput) { return { - id: createId(), + actionId: null, name: input.actionName.trim(), type: input.actionType, prompt: input.actionPrompt?.trim() || null, @@ -837,12 +849,18 @@ function requireAnimation(generation: Generation): CompleteAnimationGenerationRe if ( generation.type !== 'complete_animation' || generation.status !== 'completed' || - generation.result?.type !== 'complete_animation' || - generation.result.frames.length === 0 || - generation.result.frames.some((frame) => !frame.url) + generation.result?.type !== 'complete_animation' ) { throw new Error('完整动画任务没有返回有效帧') } + if (generation.result.frames.length !== COMPLETE_ANIMATION_FRAME_COUNT) { + throw new Error( + `动作生成应返回 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际返回 ${generation.result.frames.length} 帧`, + ) + } + if (generation.result.frames.some((frame) => !frame.url)) { + throw new Error('完整动画任务没有返回有效帧') + } return generation.result } diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts index 0713269e..c84f51d0 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts @@ -31,17 +31,13 @@ function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { generations: [], error: null, }, - { - id: 'step-action', - nodeId: 'action-node', - type: 'action', - status: 'locked', - phase: 'configuring_action', - generations: [], - error: null, - }, ], - characterInput: { prompt: '像素骑士', referenceMedia: [] }, + characterInput: { + prompt: '像素骑士', + referenceMedia: [], + spriteWidth: 256, + spriteHeight: 256, + }, characterOrigin: null, characterId: null, outfitId: null, @@ -95,7 +91,7 @@ describe('WorkflowRunRepository', () => { await expect(restored.get('run-1')).resolves.toEqual(createSnapshot()) }) - it('finds the single run bound to a character image', async () => { + it('finds the single run bound to a Character without exposing Outfit as identity', async () => { const repository = createWorkflowRunRepository({ storage: null }) const snapshot = createSnapshot() const revision = snapshot.revisions[0]! @@ -104,7 +100,7 @@ describe('WorkflowRunRepository', () => { revision.steps[0]!.generations = [ { taskId: 'character-generation-1', role: 'character_candidates' }, ] - revision.steps[1]!.status = 'active' + snapshot.status = 'completed' revision.characterId = 'character-1' revision.outfitId = 'outfit-1' revision.characterSelectedAt = '2026-08-05T00:01:00.000Z' @@ -112,23 +108,41 @@ describe('WorkflowRunRepository', () => { await repository.create(snapshot) await expect( - repository.getByCharacterOutfit({ + repository.getByCharacter({ projectId: 'project-1', characterId: 'character-1', - outfitId: 'outfit-1', }), ).resolves.toMatchObject({ id: snapshot.id }) await expect( - repository.getByCharacterOutfit({ + repository.getByCharacter({ projectId: 'project-1', - characterId: 'character-1', - outfitId: 'another-outfit', + characterId: 'another-character', }), ).resolves.toBeNull() - await expect(repository.listByCharacter('character-1')).resolves.toEqual([ - expect.objectContaining({ id: snapshot.id }), - ]) - await expect(repository.listByCharacter('another-character')).resolves.toEqual([]) + }) + + it('rejects binding the same Character to a second WorkflowRun', async () => { + const repository = createWorkflowRunRepository({ storage: null }) + const first = createSnapshot('run-1') + const second = createSnapshot('run-2') + for (const snapshot of [first, second]) { + snapshot.status = 'completed' + const revision = snapshot.revisions[0]! + revision.steps[0]!.status = 'passed' + revision.steps[0]!.phase = 'completed' + revision.steps[0]!.generations = [ + { taskId: `generation-${snapshot.id}`, role: 'character_candidates' }, + ] + revision.characterOrigin = 'generated' + revision.characterId = 'character-1' + revision.outfitId = `internal-outfit-${snapshot.id}` + revision.characterSelectedAt = '2026-08-05T00:01:00.000Z' + } + + await repository.create(first) + await expect(repository.create(second)).rejects.toThrow( + '这个 Character 已经绑定到另一条 WorkflowRun', + ) }) it('rejects a stale save instead of overwriting a newer snapshot', async () => { diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts index 69b841f4..cbb112d9 100644 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ b/frontend/src/entities/workflow-run/store/workflow-run-store.ts @@ -19,7 +19,7 @@ import { } from '../model/constants' export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' -export const WORKFLOW_RUN_STORAGE_VERSION = 7 +export const WORKFLOW_RUN_STORAGE_VERSION = 8 const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const const CHARACTER_ORIGINS = ['generated', 'uploaded'] as const @@ -36,10 +36,7 @@ interface WorkflowRunStorage { export interface WorkflowRunRepository { create(run: WorkflowRunSnapshot): Promise get(runId: WorkflowRunSnapshot['id']): Promise - getByCharacterOutfit( - binding: WorkflowRunCharacterBinding, - ): Promise - listByCharacter(characterId: string): Promise + getByCharacter(binding: WorkflowRunCharacterBinding): Promise list(projectId?: string): Promise /** expectedVersion 必须等于当前持久化版本;成功后返回 version + 1 的快照。 */ save(run: WorkflowRunSnapshot, expectedVersion: number): Promise @@ -162,17 +159,15 @@ function hasValidStepLine( return false } - if (steps.every((step) => step.status === 'passed')) return true + const character = steps[0]! + if (character.status === 'active' || character.status === 'failed') return steps.length === 1 + if (character.status !== 'passed') return false - const currentSteps = steps.filter((step) => step.status === 'active' || step.status === 'failed') - if (currentSteps.length !== 1) return false - const current = currentSteps[0]! - if (current.type === 'character') { - return steps[0] === current && steps.slice(1).every((step) => step.status === 'locked') - } + const actionSteps = steps.slice(1) + const activeActions = actionSteps.filter((step) => step.status === 'active') return ( - steps[0]?.status === 'passed' && - steps.every((step) => step === current || step.status === 'passed') + activeActions.length <= 1 && + actionSteps.every((step) => ['active', 'passed', 'failed'].includes(step.status)) ) } @@ -209,17 +204,16 @@ function hasValidRevisions(run: WorkflowRunSnapshot): boolean { const current = run.revisions.find((revision) => revision.id === run.currentRevisionId) if (!current) return false - const currentStatuses = current.steps.map((step) => step.status) - if (run.status === 'completed' && !currentStatuses.every((status) => status === 'passed')) { - return false - } - if (run.status === 'failed' && !currentStatuses.includes('failed')) return false + const activeCount = current.steps.filter((step) => step.status === 'active').length + const characterPassed = current.steps[0]?.status === 'passed' + if (run.status === 'completed' && (activeCount !== 0 || !characterPassed)) return false if ( - (run.status === 'active' || run.status === 'interrupted') && - !currentStatuses.includes('active') + run.status === 'failed' && + (activeCount !== 0 || !current.steps.some((step) => step.status === 'failed')) ) { return false } + if ((run.status === 'active' || run.status === 'interrupted') && activeCount !== 1) return false return run.revisions.every((revision, index) => { if (index === 0) { @@ -248,7 +242,13 @@ function hasValidInputs(revision: WorkflowRevision): boolean { (isRecord(characterInput) && isNonEmptyString(characterInput.prompt) && Array.isArray(characterInput.referenceMedia) && - characterInput.referenceMedia.every(isMediaReference)) + characterInput.referenceMedia.every(isMediaReference) && + typeof characterInput.spriteWidth === 'number' && + Number.isSafeInteger(characterInput.spriteWidth) && + characterInput.spriteWidth > 0 && + typeof characterInput.spriteHeight === 'number' && + Number.isSafeInteger(characterInput.spriteHeight) && + characterInput.spriteHeight > 0) if (!characterInputValid) return false const characterIsEmpty = @@ -284,7 +284,7 @@ function hasValidInputs(revision: WorkflowRevision): boolean { ([stepId, input]) => !actionStepIds.has(stepId) || !isRecord(input) || - !isNonEmptyString(input.id) || + !isNullableString(input.actionId) || !isNonEmptyString(input.name) || !isMember(input.type, ACTION_TYPES) || !isNullableString(input.prompt) || @@ -387,6 +387,7 @@ export function createWorkflowRunRepository( if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') reload() if (runs.has(run.id)) throw new Error(`WorkflowRun 已存在:${run.id}`) + assertUniqueCharacterBinding(run, runs) const saved = structuredClone(run) runs.set(saved.id, saved) try { @@ -412,6 +413,7 @@ export function createWorkflowRunRepository( if (run.version !== expectedVersion || previous.version !== expectedVersion) { throw new Error('WorkflowRun 已被其他操作更新,请刷新后重试') } + assertUniqueCharacterBinding(run, runs) const saved = { ...structuredClone(run), version: expectedVersion + 1 } runs.set(saved.id, saved) try { @@ -430,30 +432,21 @@ export function createWorkflowRunRepository( const run = runs.get(runId) return run ? structuredClone(run) : null }, - async getByCharacterOutfit(binding) { + async getByCharacter(binding) { reload() - const run = [...runs.values()] + if (!binding.projectId.trim() || !binding.characterId.trim()) { + throw new TypeError('查询 WorkflowRun 必须提供项目和角色 ID') + } + const matches = [...runs.values()] .filter((candidate) => candidate.projectId === binding.projectId) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - .find((candidate) => { + .filter((candidate) => { const revision = candidate.revisions.find( (item) => item.id === candidate.currentRevisionId, ) - return ( - revision?.characterId === binding.characterId && revision.outfitId === binding.outfitId - ) + return revision?.characterId === binding.characterId }) - return run ? structuredClone(run) : null - }, - async listByCharacter(characterId) { - reload() - if (!characterId.trim()) throw new TypeError('characterId 不能为空') - return [...runs.values()] - .filter((candidate) => - candidate.revisions.some((revision) => revision.characterId === characterId), - ) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) - .map((run) => structuredClone(run)) + if (matches.length > 1) throw new Error('同一个 Character 绑定了多条 WorkflowRun') + return matches[0] ? structuredClone(matches[0]) : null }, async list(projectId) { reload() @@ -464,3 +457,19 @@ export function createWorkflowRunRepository( save, } } + +/** 同一个项目中的 Character 只能绑定一条 WorkflowRun。 */ +function assertUniqueCharacterBinding( + run: WorkflowRunSnapshot, + runs: ReadonlyMap, +): void { + const revision = run.revisions.find((item) => item.id === run.currentRevisionId) + if (!revision?.characterId) return + + const duplicate = [...runs.values()].find((candidate) => { + if (candidate.id === run.id || candidate.projectId !== run.projectId) return false + const current = candidate.revisions.find((item) => item.id === candidate.currentRevisionId) + return current?.characterId === revision.characterId + }) + if (duplicate) throw new Error('这个 Character 已经绑定到另一条 WorkflowRun') +} diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts new file mode 100644 index 00000000..72995a08 --- /dev/null +++ b/frontend/src/features/character-setup/index.test.ts @@ -0,0 +1,10 @@ +import { expectTypeOf, it } from 'vitest' + +import type { WorkflowCharacterInput } from '@/entities' +import type { CharacterSetupProps } from '.' + +it('submits WorkflowRun character setup input', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf() +}) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts index 44c9a7b0..87d909b5 100644 --- a/frontend/src/features/character-setup/index.ts +++ b/frontend/src/features/character-setup/index.ts @@ -1,7 +1,7 @@ -import type { CreateCharacterInput } from '@/entities' +import type { WorkflowCharacterInput } from '@/entities' /** 填写角色资料并提交母版生成。 */ export interface CharacterSetupProps { projectId: string - onSubmit(input: CreateCharacterInput): void + onSubmit(input: WorkflowCharacterInput): void } diff --git a/frontend/src/features/workflow-controller/README.md b/frontend/src/features/workflow-controller/README.md index 018fbdce..fe5016b4 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -1,56 +1,28 @@ # WorkflowController -WorkflowController 是页面与 `WorkflowRun` Entity 之间的协调层,不是另一套工作流引擎。 - -## 调用关系 +WorkflowController 是页面命令适配层,不是 WorkflowRun 的父级,也不保存第二套状态。 ```text -Quick Start / Workflow Editor - -> WorkflowController(页面命令与恢复分流) - -> WorkflowRunService(创建或恢复绑定 Run) - -> WorkflowRun(状态迁移与业务用例) - -> GenerationTask / Character API +Quick Start --------┐ + ├─> WorkflowController -> WorkflowRunService +Workflow Editor ----┘ -> Repository + -> Generation / Character APIs ``` -Quick Start Service 与 Workflow Editor Service 都只是页面适配器。两者分别依赖同一个 -WorkflowController,彼此之间不能调用,也不能把 Quick Start 的方法注入 Workflow Editor。 - -## 职责 - -- 把创建角色、追加动作、候选确认、审核通过和节点重做整理成页面命令。 -- 根据当前 Revision、活动卡片和 `phase` 返回页面可直接渲染的状态。 -- 合并同一个 Run 的并发恢复请求,避免 React StrictMode 或路由重进重复恢复任务。 -- 在一个 Controller 生命周期内复用同一个绑定 Run,使“生成中断”能作用于正在等待结果的实例。 -- 在中断、继续、配置动作和节点重做后立即保存 WorkflowRun 检查点。 -- `nextStep` 根据当前卡片 phase 分流;缺少选图、动作配置或审核决定时只返回页面状态, - 不替用户作决定。 -- 上传母版时先调用注入的媒体上传函数;上传成功后再创建 Run,并让 WorkflowRun 立即创建、 - 绑定正式 Character/Outfit。 - -## 页面命令 +## 负责什么 -| 页面操作 | Controller 命令 | 结果 | -| --- | --- | --- | -| 开始生成角色 | `startCharacter` | 返回四张角色候选 | -| 从上传母版开始 | `startCharacterFromUploadedTemplate` | 上传成功后进入动作配置 | -| 选定角色 | `confirmCharacter` | 进入动作配置 | -| 给已有角色图追加动作 | `startAction` | 在原 Run 追加 Action Step,返回四张首帧候选 | -| 配置首个动作 | `configureAction` | 保存检查点并生成首帧候选 | -| 选定动作首帧 | `confirmActionFirstFrame` | 恢复完整动画并进入审核 | -| 审核通过 | `approveAction` | 将动作写回 Character | -| 页面刷新或重新进入 | `resume` | 恢复到可直接渲染的页面阶段 | -| 通用推进 | `nextStep` | 按当前 phase 返回或推进一个页面步骤 | +- 把页面动作转成统一命令,例如创建 Run、确认角色、追加动作、确认首帧和批准动作。 +- 每次根据 `runId` 从 WorkflowRunService 获取绑定实例,再调用实体方法。 +- 在中断、继续、动作配置和 Revision 重启后保存快照。 +- 按 `projectId + characterId` 找到角色唯一的 WorkflowRun;新增动作追加到原 Run。 -## 不负责 +## 不负责什么 -- 不保存第二份 WorkflowRun 快照,也不直接访问 Repository 或 localStorage。实例缓存只用于保证 - 同一页面会话中的命令操作同一个绑定 Run;页面刷新会重新创建 Controller 并从 Service 恢复。 -- 不提供 `subscribe` / `subscribeAll`;页面操作本身知道状态何时改变。 -- 不为新增动作创建第二个 Run;角色图归属由 WorkflowRun Service 按 - `projectId + characterId + outfitId` 统一定位。 -- 不解释 GenerationTask 的内部执行方式,不整理最终模型提示词。 -- 不直接调用模型或 Character API,这些由 WorkflowRun Entity 组合;Controller 只调用注入的 - Media 上传边界,不接触具体存储供应商。 +- 不管理 Quick Start、Workflow Editor 或 Playtest 的页面状态。 +- 不直接访问 localStorage,不提供 `subscribe` / `subscribeAll`。 +- 不解释 SSE 消息,不整理模型提示词,不调用模型供应商。 +- 不生成资产 ID,也不把 WorkflowRun 临时结果伪装成 Character。 -Controller 依赖 WorkflowRun 的异步 Service 接口,因此以后把本地 Repository 替换成后端接口时, -页面命令不需要从同步调用整体改写为异步调用。 +Quick Start 会自动连续调用 Controller;Workflow Editor 等用户逐步点击后再调用。两者是同级 +页面适配器,不能互相注入方法。Controller 的所有读取和命令均为异步接口,后续 Repository +从 localStorage 换成后端 HTTP 时,页面调用方式不需要改变。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index 7d1766b6..c019afe5 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1,75 +1,14 @@ -/** WorkflowController 只测试页面协调,不重复测试 WorkflowRun 内部生成逻辑。 */ - import { describe, expect, it, vi } from 'vitest' -import type { - ActionFirstFrameCandidateBatch, - ActionReviewResult, - CharacterCandidateBatch, - MediaReference, - PublishActionResult, - WorkflowRun, - WorkflowRunService, - WorkflowRunSnapshot, - WorkflowStep, - WorkflowStepPhase, -} from '@/entities' +import type { WorkflowRunHandle, WorkflowRunService, WorkflowRunSnapshot } from '@/entities' import { createWorkflowController } from './controller' -function createSnapshot( - phase: WorkflowStepPhase, - options: { - id?: string - status?: WorkflowRunSnapshot['status'] - } = {}, -): WorkflowRunSnapshot { - const id = options.id ?? 'run-1' - const status = options.status ?? 'active' - const isCharacterPhase = - phase === 'generating_character_candidates' || phase === 'selecting_character' - const isTerminal = status === 'completed' - const steps: WorkflowStep[] = [ - { - id: 'character-step', - nodeId: 'builtin-character', - type: 'character', - status: isTerminal || !isCharacterPhase ? 'passed' : 'active', - phase: isTerminal || !isCharacterPhase ? 'completed' : phase, - generations: [], - error: null, - }, - ] - if (isCharacterPhase) { - steps.push({ - id: 'action-step', - nodeId: 'builtin-action', - type: 'action', - status: 'locked', - phase: 'configuring_action', - generations: [], - error: null, - }) - } else { - steps.push({ - id: 'action-step', - nodeId: 'builtin-action', - type: 'action', - status: isTerminal ? 'passed' : 'active', - phase: isTerminal ? 'completed' : phase, - generations: [], - error: null, - }) - } - +function snapshot(status: WorkflowRunSnapshot['status'] = 'completed'): WorkflowRunSnapshot { return { - id, + id: 'run-1', projectId: 'project-1', version: 1, - source: { - type: 'builtin', - key: 'character_action', - rootNodeId: 'builtin-character', - }, + source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, status, currentRevisionId: 'revision-1', revisions: [ @@ -77,25 +16,29 @@ function createSnapshot( id: 'revision-1', parentRevisionId: null, restartedFromStepId: null, - steps, - characterInput: { prompt: '像素风守夜人', referenceMedia: [] }, - characterOrigin: isCharacterPhase ? null : 'generated', - characterId: isCharacterPhase ? null : 'character-1', - outfitId: isCharacterPhase ? null : 'outfit-1', - characterSelectedAt: isCharacterPhase ? null : '2026-08-06T00:00:00.000Z', - actionInputs: - isCharacterPhase || phase === 'configuring_action' - ? {} - : { - 'action-step': { - id: 'action-1', - name: '行走', - type: 'walk', - prompt: null, - fps: 12, - }, - }, createdAt: '2026-08-06T00:00:00.000Z', + steps: [ + { + id: 'character-step', + nodeId: 'character-node', + type: 'character', + status: 'passed', + phase: 'completed', + generations: [], + error: null, + }, + ], + characterInput: { + prompt: '守夜人', + referenceMedia: [], + spriteWidth: 256, + spriteHeight: 256, + }, + characterOrigin: 'generated', + characterId: 'character-1', + outfitId: 'outfit-1', + characterSelectedAt: '2026-08-06T00:00:00.000Z', + actionInputs: {}, }, ], createdAt: '2026-08-06T00:00:00.000Z', @@ -103,29 +46,18 @@ function createSnapshot( } } -function createRun(initial: WorkflowRunSnapshot): WorkflowRun { - let state = structuredClone(initial) - const run = { - id: initial.id, - snapshot: vi.fn(() => structuredClone(state)), - save: vi.fn(async () => structuredClone(state)), +function handle(state = snapshot()): WorkflowRunHandle { + let current = structuredClone(state) + return { + id: current.id, + snapshot: () => structuredClone(current), + save: vi.fn(async () => structuredClone(current)), interrupt: vi.fn(() => { - state.status = 'interrupted' - return structuredClone(state) - }), - continue: vi.fn(() => { - state.status = 'active' - return structuredClone(state) - }), - restartFromStep: vi.fn((stepId: string) => { - const revision = state.revisions[0]! - const step = revision.steps.find((item) => item.id === stepId)! - step.status = 'active' - step.phase = - step.type === 'character' ? 'generating_character_candidates' : 'configuring_action' - state.status = 'active' - return structuredClone(state) + current.status = 'interrupted' + return structuredClone(current) }), + continue: vi.fn(() => structuredClone(current)), + restartFromStep: vi.fn(() => structuredClone(current)), start: vi.fn(), resumeCharacterCandidates: vi.fn(), confirmCharacter: vi.fn(), @@ -138,307 +70,42 @@ function createRun(initial: WorkflowRunSnapshot): WorkflowRun { getActionReview: vi.fn(), approveAction: vi.fn(), } - return run as unknown as WorkflowRun -} - -function createFixture( - runs: WorkflowRun[] = [], - uploadCharacterTemplate?: (file: File, signal?: AbortSignal) => Promise, -) { - const byId = new Map(runs.map((run) => [run.id, run])) - const service = { - create: vi.fn(), - get: vi.fn(async (runId: string) => byId.get(runId) ?? null), - getByCharacterOutfit: vi.fn(), - listByCharacter: vi.fn(), - appendAction: vi.fn(), - } as unknown as WorkflowRunService - return { - service, - controller: createWorkflowController({ service, uploadCharacterTemplate }), - } -} - -function characterBatch(snapshot: WorkflowRunSnapshot): CharacterCandidateBatch { - return { - snapshot, - generationId: 'character-generation-1', - candidates: ['character-1.png', 'character-2.png', 'character-3.png', 'character-4.png'], - } -} - -function actionBatch(snapshot: WorkflowRunSnapshot): ActionFirstFrameCandidateBatch { - return { - snapshot, - candidateTaskIds: ['frame-1', 'frame-2', 'frame-3', 'frame-4'], - candidates: ['frame-1.png', 'frame-2.png', 'frame-3.png', 'frame-4.png'], - } } -function actionReview(snapshot: WorkflowRunSnapshot): ActionReviewResult { +function service(run = handle()): WorkflowRunService { return { - snapshot, - generationId: 'animation-1', - frames: [{ imageUrl: 'animation-1.png' }, { imageUrl: 'animation-2.png' }], + create: vi.fn(async () => run), + get: vi.fn(async () => run), + getByCharacter: vi.fn(async () => run), + appendAction: vi.fn(async () => run), } } -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((nextResolve) => { - resolve = nextResolve - }) - return { promise, resolve } -} - describe('WorkflowController', () => { - it('creates a character run and appends actions to that same run', async () => { - const characterRun = createRun(createSnapshot('generating_character_candidates')) - const actionRun = createRun(createSnapshot('generating_action_candidates')) - vi.mocked(characterRun.start).mockResolvedValue(characterBatch(characterRun.snapshot())) - vi.mocked(actionRun.start).mockResolvedValue(actionBatch(actionRun.snapshot())) - const { controller, service } = createFixture() - vi.mocked(service.create).mockResolvedValue(characterRun) - vi.mocked(service.appendAction).mockResolvedValue(actionRun) + it('delegates creation and character lookup to the shared WorkflowRunService', async () => { + const workflowService = service() + const controller = createWorkflowController({ workflowService }) await expect( - controller.startCharacter({ - projectId: 'project-1', - characterPrompt: '像素风守夜人', - }), - ).resolves.toMatchObject({ generationId: 'character-generation-1' }) + controller.create({ projectId: 'project-1', characterPrompt: '守夜人' }), + ).resolves.toMatchObject({ id: 'run-1' }) await expect( - controller.startAction({ - projectId: 'project-1', - characterId: 'character-1', - outfitId: 'outfit-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }), - ).resolves.toMatchObject({ - candidateTaskIds: ['frame-1', 'frame-2', 'frame-3', 'frame-4'], - }) - - await controller.interrupt(characterRun.id) - await expect(controller.getWorkflow(characterRun.id)).resolves.toMatchObject({ - status: 'interrupted', - }) - expect(service.get).not.toHaveBeenCalled() - }) - - it('confirms the character and checkpoints action configuration before generation', async () => { - const run = createRun(createSnapshot('configuring_action')) - const snapshot = run.snapshot() - vi.mocked(run.confirmCharacter).mockResolvedValue(snapshot) - vi.mocked(run.resumeActionFirstFrameCandidates).mockResolvedValue(actionBatch(snapshot)) - const { controller } = createFixture([run]) - - await expect(controller.confirmCharacter(run.id, 'character-2.png')).resolves.toEqual({ - phase: 'action-setup', - snapshot, - }) - await controller.configureAction(run.id, { - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - - expect(run.configureAction).toHaveBeenCalledOnce() - expect(run.save).toHaveBeenCalledOnce() - expect(run.resumeActionFirstFrameCandidates).toHaveBeenCalledOnce() + controller.getByCharacter({ projectId: 'project-1', characterId: 'character-1' }), + ).resolves.toMatchObject({ id: 'run-1' }) + expect(workflowService.create).toHaveBeenCalledTimes(1) + expect(workflowService.getByCharacter).toHaveBeenCalledTimes(1) }) - it('keeps uploaded-template orchestration in the controller', async () => { - const createdRun = createRun(createSnapshot('generating_character_candidates')) - const existingRun = createRun( - createSnapshot('generating_character_candidates', { id: 'run-existing' }), - ) - const accepted = createSnapshot('configuring_action') - vi.mocked(createdRun.acceptUploadedCharacterTemplate).mockResolvedValue(accepted) - vi.mocked(existingRun.acceptUploadedCharacterTemplate).mockResolvedValue(accepted) - const upload = vi.fn(async () => 'media://template' as MediaReference) - const { controller, service } = createFixture([existingRun], upload) - vi.mocked(service.create).mockResolvedValue(createdRun) - const file = new File(['template'], 'template.png', { type: 'image/png' }) + it('persists local interrupt and restart mutations exactly once', async () => { + const run = handle(snapshot('active')) + const controller = createWorkflowController({ workflowService: service(run) }) - await expect( - controller.startCharacterFromUploadedTemplate( - { projectId: 'project-1', characterPrompt: '像素守夜人' }, - file, - ), - ).resolves.toEqual({ phase: 'action-setup', snapshot: accepted }) - expect(upload.mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(service.create).mock.invocationCallOrder[0]!, - ) - expect(createdRun.acceptUploadedCharacterTemplate).toHaveBeenCalledWith({ - referenceMedia: 'media://template', - description: '像素守夜人', - }) + await expect(controller.interrupt('run-1')).resolves.toMatchObject({ status: 'interrupted' }) + expect(run.interrupt).toHaveBeenCalledTimes(1) + expect(run.save).toHaveBeenCalledTimes(1) - await expect( - controller.continueWithUploadedTemplate(existingRun.id, file, '已有角色'), - ).resolves.toEqual({ phase: 'action-setup', snapshot: accepted }) - expect(existingRun.acceptUploadedCharacterTemplate).toHaveBeenCalledWith({ - referenceMedia: 'media://template', - description: '已有角色', - }) - }) - - it('maps persisted phases to page snapshots', async () => { - const characterRun = createRun(createSnapshot('selecting_character')) - const actionRun = createRun(createSnapshot('selecting_action_frame', { id: 'run-2' })) - const reviewingRun = createRun(createSnapshot('reviewing_animation', { id: 'run-3' })) - vi.mocked(characterRun.resumeCharacterCandidates).mockResolvedValue( - characterBatch(characterRun.snapshot()), - ) - vi.mocked(actionRun.resumeActionFirstFrameCandidates).mockResolvedValue( - actionBatch(actionRun.snapshot()), - ) - vi.mocked(reviewingRun.getActionReview).mockResolvedValue(actionReview(reviewingRun.snapshot())) - const { controller } = createFixture([characterRun, actionRun, reviewingRun]) - - await expect(controller.resume(characterRun.id)).resolves.toMatchObject({ - phase: 'character-candidates', - }) - await expect(controller.resume(actionRun.id)).resolves.toMatchObject({ - phase: 'action-first-frame-candidates', - }) - await expect(controller.resume(reviewingRun.id)).resolves.toMatchObject({ - phase: 'action-review', - }) - }) - - it('uses nextStep as a phase-driven entry without inventing user choices', async () => { - const characterRun = createRun(createSnapshot('selecting_character')) - const actionRun = createRun(createSnapshot('configuring_action', { id: 'run-2' })) - const confirmedSnapshot = createSnapshot('configuring_action') - vi.mocked(characterRun.resumeCharacterCandidates).mockResolvedValue( - characterBatch(characterRun.snapshot()), - ) - vi.mocked(characterRun.confirmCharacter).mockResolvedValue(confirmedSnapshot) - vi.mocked(actionRun.resumeActionFirstFrameCandidates).mockResolvedValue( - actionBatch(actionRun.snapshot()), - ) - const { controller } = createFixture([characterRun, actionRun]) - - await expect(controller.nextStep(characterRun.id)).resolves.toMatchObject({ - phase: 'character-candidates', - }) - expect(characterRun.confirmCharacter).not.toHaveBeenCalled() - await expect( - controller.nextStep(characterRun.id, { selectedImageUrl: 'character-2.png' }), - ).resolves.toEqual({ phase: 'action-setup', snapshot: confirmedSnapshot }) - - await expect( - controller.nextStep(actionRun.id, { - action: { actionName: '行走', actionType: 'walk', fps: 12 }, - }), - ).resolves.toMatchObject({ phase: 'action-first-frame-candidates' }) - expect(actionRun.configureAction).toHaveBeenCalledOnce() - expect(actionRun.save).toHaveBeenCalledOnce() - }) - - it('lets nextStep publish only after the page explicitly approves review', async () => { - const run = createRun(createSnapshot('reviewing_animation')) - const completed = createSnapshot('completed', { status: 'completed' }) - vi.mocked(run.getActionReview).mockResolvedValue(actionReview(run.snapshot())) - vi.mocked(run.approveAction).mockResolvedValue({ - snapshot: completed, - character: { id: 'character-1' }, - characterId: 'character-1', - outfitId: 'outfit-1', - actionId: 'action-1', - } as unknown as PublishActionResult) - const { controller } = createFixture([run]) - - await expect(controller.nextStep(run.id)).resolves.toMatchObject({ phase: 'action-review' }) - expect(run.approveAction).not.toHaveBeenCalled() - await expect(controller.nextStep(run.id, { approve: true })).resolves.toEqual({ - phase: 'terminal', - snapshot: completed, - }) - }) - - it('shares one in-flight recovery for concurrent route entries', async () => { - const run = createRun(createSnapshot('selecting_character')) - const pending = deferred() - vi.mocked(run.resumeCharacterCandidates).mockReturnValue(pending.promise) - const { controller, service } = createFixture([run]) - - const first = controller.resume(run.id) - const second = controller.resume(run.id) - expect(service.get).toHaveBeenCalledTimes(1) - pending.resolve(characterBatch(run.snapshot())) - - await expect(Promise.all([first, second])).resolves.toEqual([ - expect.objectContaining({ phase: 'character-candidates' }), - expect.objectContaining({ phase: 'character-candidates' }), - ]) - }) - - it('reuses the bound run when a page command arrives during recovery', async () => { - const run = createRun(createSnapshot('selecting_character')) - const pending = deferred() - vi.mocked(run.resumeCharacterCandidates).mockReturnValue(pending.promise) - const { controller, service } = createFixture([run]) - - const recovery = controller.resume(run.id) - await vi.waitFor(() => expect(run.resumeCharacterCandidates).toHaveBeenCalledOnce()) - await controller.interrupt(run.id) - - expect(service.get).toHaveBeenCalledOnce() - expect(run.interrupt).toHaveBeenCalledOnce() - pending.resolve(characterBatch(run.snapshot())) - await recovery - }) - - it('does not submit animation again after the run has advanced', async () => { - const run = createRun(createSnapshot('reviewing_animation')) - const review = actionReview(run.snapshot()) - vi.mocked(run.getActionReview).mockResolvedValue(review) - const { controller } = createFixture([run]) - - await expect(controller.confirmActionFirstFrame(run.id, 'frame-2.png')).resolves.toEqual(review) - expect(run.confirmActionFirstFrame).not.toHaveBeenCalled() - expect(run.resumeAction).not.toHaveBeenCalled() - }) - - it('persists interrupt, continue and revision restart checkpoints', async () => { - const run = createRun(createSnapshot('configuring_action')) - const { controller } = createFixture([run]) - - await expect(controller.interrupt(run.id)).resolves.toMatchObject({ - status: 'interrupted', - }) - await expect(controller.continue(run.id)).resolves.toMatchObject({ - phase: 'action-setup', - }) - await expect(controller.restartFromStep(run.id, 'action-step')).resolves.toMatchObject({ - phase: 'action-setup', - }) - - expect(run.save).toHaveBeenCalledTimes(3) + await controller.restartAction('run-1', 'action-step') expect(run.restartFromStep).toHaveBeenCalledWith('action-step') - }) - - it('returns terminal snapshots and delegates approved action publication', async () => { - const run = createRun(createSnapshot('completed', { status: 'completed' })) - const published = { - snapshot: run.snapshot(), - character: { id: 'character-1' }, - characterId: 'character-1', - outfitId: 'outfit-1', - actionId: 'action-1', - } as unknown as PublishActionResult - vi.mocked(run.approveAction).mockResolvedValue(published) - const { controller } = createFixture([run]) - - await expect(controller.resume(run.id)).resolves.toMatchObject({ - phase: 'terminal', - }) - await expect(controller.getWorkflow(run.id)).resolves.toEqual(run.snapshot()) - await expect(controller.approveAction(run.id)).resolves.toEqual(published) - await expect(controller.resume('missing')).resolves.toBeNull() + expect(run.save).toHaveBeenCalledTimes(2) }) }) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index e03e266f..df690237 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,277 +1,106 @@ -/** - * 页面与 WorkflowRun Entity 之间的协调层。 - * - * WorkflowRun 实例拥有状态迁移、GenerationTask 恢复和 Character 写入;Controller - * 只把页面命令整理成稳定入口,并把 Run 的业务 phase 映射为页面 phase。它不持有 - * 第二份状态、不直接访问 Repository,也不关心后端如何整理提示词或执行生成。 - */ - import type { + AcceptUploadedCharacterTemplateInput, ActionFirstFrameCandidateBatch, ActionReviewResult, AppendWorkflowActionInput, CharacterCandidateBatch, ConfigureWorkflowActionInput, CreateWorkflowRunInput, - MediaReference, PublishActionResult, - WorkflowRun, + WorkflowRunCharacterBinding, + WorkflowRunHandle, WorkflowRunService, WorkflowRunSnapshot, - WorkflowStep, } from '@/entities' -type CharacterRunInput = CreateWorkflowRunInput -type AddActionInput = AppendWorkflowActionInput - -/** 页面恢复后可直接渲染的状态;候选 URL 和审核帧不写入 WorkflowRun 快照。 */ -export type WorkflowControllerSnapshot = - | ({ phase: 'character-candidates' } & CharacterCandidateBatch) - | { phase: 'action-setup'; snapshot: WorkflowRunSnapshot } - | ({ - phase: 'action-first-frame-candidates' - } & ActionFirstFrameCandidateBatch) - | ({ phase: 'action-review' } & ActionReviewResult) - | { phase: 'interrupted'; snapshot: WorkflowRunSnapshot } - | { phase: 'terminal'; snapshot: WorkflowRunSnapshot } - /** - * nextStep 只接收当前卡片可能需要的用户决定。 - * Controller 会按真实 phase 解释字段,因此页面不需要复制工作流状态机。 + * 页面级流程协调器。 + * + * 它不保存第二份 WorkflowRun,也不解释生成任务。每次命令都先从同一个 Service 取得 + * 绑定 Run,再调用领域方法。Quick Start 自动连续调用这些命令,Workflow Editor 则等 + * 用户逐步点击;两种页面因此共享完全相同的状态转换规则。 */ -export interface WorkflowNextStepInput { - selectedImageUrl?: string - action?: ConfigureWorkflowActionInput - approve?: boolean -} - export interface WorkflowController { - /** 创建“角色 + 首个动作”Run,并开始生成四张角色候选。 */ - startCharacter(input: CharacterRunInput): Promise - /** 先上传成功,再创建 Run 和正式 Character,避免留下上传失败的空运行。 */ - startCharacterFromUploadedTemplate( - input: CharacterRunInput, - file: File, - signal?: AbortSignal, - ): Promise> - /** 已有 Run 在角色生成开始前采用上传母版。 */ - continueWithUploadedTemplate( - runId: WorkflowRun['id'], - file: File, - description?: string | null, - signal?: AbortSignal, - ): Promise> - /** 确认角色后仍留在同一个 Run,进入动作配置。 */ - confirmCharacter( - runId: WorkflowRun['id'], - selectedImageUrl: string, - ): Promise> - /** 在该角色图原有 Run 中追加动作卡片,并开始生成四张动作首帧候选。 */ - startAction(input: AddActionInput): Promise - /** 为角色创建 Run 配置首个动作,并开始生成动作首帧候选。 */ - configureAction( - runId: WorkflowRun['id'], - input: ConfigureWorkflowActionInput, - ): Promise - /** 确认首帧后恢复到完整动画审核结果;重复调用不会重复提交生成。 */ - confirmActionFirstFrame( - runId: WorkflowRun['id'], - selectedImageUrl: string, - ): Promise - /** 审核通过后把动作写回 Character。 */ - approveAction(runId: WorkflowRun['id']): Promise - /** - * 通用推进入口。Quick Start 可以连续调用;Workflow Editor 每次点击调用一次。 - * 到达选图、动作配置或审核阶段且没有对应输入时,只返回当前可渲染状态。 - */ - nextStep( - runId: WorkflowRun['id'], - input?: WorkflowNextStepInput, - ): Promise - /** 中断或继续 Run 时立即保存检查点。 */ - interrupt(runId: WorkflowRun['id']): Promise - continue(runId: WorkflowRun['id']): Promise - /** Workflow Editor 从指定卡片创建 Revision,并恢复新分支页面。 */ - restartFromStep( - runId: WorkflowRun['id'], - stepId: WorkflowStep['id'], - ): Promise - /** 读取可渲染快照;不存在时返回 null。 */ - getWorkflow(runId: WorkflowRun['id']): Promise - /** 页面刷新和路由重进时的唯一恢复入口。 */ - resume(runId: WorkflowRun['id']): Promise + create(input: CreateWorkflowRunInput): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + getByCharacter(binding: WorkflowRunCharacterBinding): Promise + start(runId: string): Promise + resumeCharacterCandidates(runId: string): Promise + confirmCharacter(runId: string, selectedImageUrl: string): Promise + acceptUploadedCharacterTemplate( + runId: string, + input: AcceptUploadedCharacterTemplateInput, + ): Promise + appendAction(input: AppendWorkflowActionInput): Promise + configureAction(runId: string, input: ConfigureWorkflowActionInput): Promise + resumeActionFirstFrameCandidates(runId: string): Promise + confirmActionFirstFrame(runId: string, selectedImageUrl: string): Promise + resumeAction(runId: string): Promise + getActionReview(runId: string): Promise + approveAction(runId: string): Promise + interrupt(runId: string): Promise + continue(runId: string): Promise + restartAction(runId: string, stepId: string): Promise } export interface CreateWorkflowControllerOptions { - /** Controller 只通过 Service 创建或恢复绑定后的 Run。 */ - service: WorkflowRunService - /** Media Entity 的上传适配器;Controller 只接收不透明 MediaReference。 */ - uploadCharacterTemplate?: (file: File, signal?: AbortSignal) => Promise + workflowService: WorkflowRunService } export function createWorkflowController({ - service, - uploadCharacterTemplate, + workflowService, }: CreateWorkflowControllerOptions): WorkflowController { - // 一个页面会围绕同一个 Run 连续发出恢复、中断和确认命令。复用绑定实例可以让 - // 中断立即改变正在等待异步结果的那份运行状态,避免旧实例稍后继续推进并覆盖检查点。 - const runs = new Map() - const pendingLoads = new Map>() - const pendingResumes = new Map>() - - function rememberRun(run: WorkflowRun): WorkflowRun { - runs.set(run.id, run) - return run - } - - async function loadRun(runId: WorkflowRun['id']): Promise { - const loaded = runs.get(runId) - if (loaded) return loaded - - const pending = pendingLoads.get(runId) - if (pending) return pending - - const request = service.get(runId).then((run) => (run ? rememberRun(run) : null)) - pendingLoads.set(runId, request) - const clear = () => { - if (pendingLoads.get(runId) === request) pendingLoads.delete(runId) - } - void request.then(clear, clear) - return request - } - - async function requireRun(runId: WorkflowRun['id']): Promise { - const run = await loadRun(runId) + async function requireRun(runId: string): Promise { + const run = await workflowService.get(runId) if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) return run } - async function resume(runId: WorkflowRun['id']): Promise { - const pending = pendingResumes.get(runId) - if (pending) return pending - - const request = loadRun(runId).then((run) => (run ? restoreRun(run) : null)) - pendingResumes.set(runId, request) - const clear = () => { - if (pendingResumes.get(runId) === request) pendingResumes.delete(runId) - } - void request.then(clear, clear) - return request - } - - async function confirmActionFirstFrame( - runId: WorkflowRun['id'], - selectedImageUrl: string, - ): Promise { - const run = await requireRun(runId) - const step = currentStep(run.snapshot()) - - if (step.phase === 'selecting_action_frame') { - await run.confirmActionFirstFrame(selectedImageUrl) - } else if (step.phase === 'generating_animation') { - await run.resumeAction() - } else if (step.phase !== 'reviewing_animation') { - throw new Error(`动作当前不能确认首帧:${step.phase}`) - } - return run.getActionReview() - } - - async function nextStep( - runId: WorkflowRun['id'], - input: WorkflowNextStepInput = {}, - ): Promise { - const run = await requireRun(runId) - const snapshot = run.snapshot() - if (snapshot.status === 'interrupted') { - run.continue() - await run.save() - return restoreRun(run) - } - if (snapshot.status !== 'active') return { phase: 'terminal', snapshot } - - const step = currentStep(snapshot) - if (step.type === 'character') { - if (step.phase === 'selecting_character' && input.selectedImageUrl) { - return { - phase: 'action-setup', - snapshot: await run.confirmCharacter(input.selectedImageUrl), - } - } - return restoreRun(run) - } - - if (step.phase === 'configuring_action') { - if (!input.action) return { phase: 'action-setup', snapshot } - run.configureAction(input.action) - await run.save() - return { - phase: 'action-first-frame-candidates', - ...(await run.resumeActionFirstFrameCandidates()), - } - } - if (step.phase === 'selecting_action_frame' && input.selectedImageUrl) { - return { - phase: 'action-review', - ...(await confirmActionFirstFrame(runId, input.selectedImageUrl)), - } - } - if (step.phase === 'reviewing_animation' && input.approve) { - const published = await run.approveAction() - return { phase: 'terminal', snapshot: published.snapshot } - } - return restoreRun(run) - } - return { - async startCharacter(input) { - const run = rememberRun(await service.create(input)) - const result = await run.start() - if (!isCharacterBatch(result)) throw new Error('角色 Run 没有返回角色候选') - return result + async create(input) { + return (await workflowService.create(input)).snapshot() }, - async startCharacterFromUploadedTemplate(input, file, signal) { - if (!uploadCharacterTemplate) throw new Error('角色母版上传服务尚未配置') - const referenceMedia = await uploadCharacterTemplate(file, signal) - const run = rememberRun(await service.create(input)) - return { - phase: 'action-setup', - snapshot: await run.acceptUploadedCharacterTemplate({ - referenceMedia, - description: input.characterPrompt, - }), - } + async get(runId) { + return (await workflowService.get(runId))?.snapshot() ?? null }, - async continueWithUploadedTemplate(runId, file, description, signal) { - if (!uploadCharacterTemplate) throw new Error('角色母版上传服务尚未配置') - const referenceMedia = await uploadCharacterTemplate(file, signal) - const run = await requireRun(runId) - return { - phase: 'action-setup', - snapshot: await run.acceptUploadedCharacterTemplate({ referenceMedia, description }), - } + async getByCharacter(binding) { + return (await workflowService.getByCharacter(binding))?.snapshot() ?? null + }, + async start(runId) { + return (await requireRun(runId)).start() + }, + async resumeCharacterCandidates(runId) { + return (await requireRun(runId)).resumeCharacterCandidates() }, async confirmCharacter(runId, selectedImageUrl) { - const run = await requireRun(runId) - const snapshot = await run.confirmCharacter(selectedImageUrl) - return { phase: 'action-setup', snapshot } + return (await requireRun(runId)).confirmCharacter(selectedImageUrl) + }, + async acceptUploadedCharacterTemplate(runId, input) { + return (await requireRun(runId)).acceptUploadedCharacterTemplate(input) }, - async startAction(input) { - const run = rememberRun(await service.appendAction(input)) - const result = await run.start() - if (!isActionBatch(result)) throw new Error('动作 Run 没有返回首帧候选') - return result + async appendAction(input) { + return (await workflowService.appendAction(input)).snapshot() }, async configureAction(runId, input) { const run = await requireRun(runId) run.configureAction(input) - await run.save() - return run.resumeActionFirstFrameCandidates() + return run.save() + }, + async resumeActionFirstFrameCandidates(runId) { + return (await requireRun(runId)).resumeActionFirstFrameCandidates() + }, + async confirmActionFirstFrame(runId, selectedImageUrl) { + return (await requireRun(runId)).confirmActionFirstFrame(selectedImageUrl) + }, + async resumeAction(runId) { + return (await requireRun(runId)).resumeAction() + }, + async getActionReview(runId) { + return (await requireRun(runId)).getActionReview() }, - confirmActionFirstFrame, async approveAction(runId) { return (await requireRun(runId)).approveAction() }, - nextStep, async interrupt(runId) { const run = await requireRun(runId) run.interrupt() @@ -280,68 +109,12 @@ export function createWorkflowController({ async continue(runId) { const run = await requireRun(runId) run.continue() - await run.save() - return restoreRun(run) + return run.save() }, - async restartFromStep(runId, stepId) { + async restartAction(runId, stepId) { const run = await requireRun(runId) run.restartFromStep(stepId) - await run.save() - return restoreRun(run) - }, - async getWorkflow(runId) { - return (await loadRun(runId))?.snapshot() ?? null + return run.save() }, - resume, } } - -async function restoreRun(run: WorkflowRun): Promise { - const snapshot = run.snapshot() - if (snapshot.status === 'interrupted') return { phase: 'interrupted', snapshot } - if (snapshot.status !== 'active') return { phase: 'terminal', snapshot } - - const step = currentStep(snapshot) - if (step.type === 'character') { - if (step.phase !== 'generating_character_candidates' && step.phase !== 'selecting_character') { - throw new Error(`角色卡片无法恢复阶段:${step.phase}`) - } - return { - phase: 'character-candidates', - ...(await run.resumeCharacterCandidates()), - } - } - - if (step.phase === 'configuring_action') return { phase: 'action-setup', snapshot } - if (step.phase === 'generating_action_candidates' || step.phase === 'selecting_action_frame') { - return { - phase: 'action-first-frame-candidates', - ...(await run.resumeActionFirstFrameCandidates()), - } - } - if (step.phase === 'generating_animation') await run.resumeAction() - if (step.phase === 'generating_animation' || step.phase === 'reviewing_animation') { - return { phase: 'action-review', ...(await run.getActionReview()) } - } - throw new Error(`动作卡片无法恢复阶段:${step.phase}`) -} - -function currentStep(snapshot: WorkflowRunSnapshot): WorkflowStep { - const revision = snapshot.revisions.find((item) => item.id === snapshot.currentRevisionId) - if (!revision) throw new Error(`WorkflowRun ${snapshot.id} 的 currentRevisionId 无效`) - const step = revision.steps.find((item) => item.status === 'active') - if (!step) throw new Error(`WorkflowRun ${snapshot.id} 没有活动卡片`) - return step -} - -function isCharacterBatch( - value: CharacterCandidateBatch | ActionFirstFrameCandidateBatch, -): value is CharacterCandidateBatch { - return 'generationId' in value -} - -function isActionBatch( - value: CharacterCandidateBatch | ActionFirstFrameCandidateBatch, -): value is ActionFirstFrameCandidateBatch { - return 'candidateTaskIds' in value -} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index 32bedd62..542dbad1 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,9 +1,2 @@ -/** WorkflowController Feature 的唯一公开入口。 */ - export { createWorkflowController } from './controller' -export type { - CreateWorkflowControllerOptions, - WorkflowController, - WorkflowControllerSnapshot, - WorkflowNextStepInput, -} from './controller' +export type { CreateWorkflowControllerOptions, WorkflowController } from './controller' From a2da43d617167262625498c7fceec4327a3803b7 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:04:35 +0800 Subject: [PATCH 25/27] refactor(frontend): make WorkflowRun the aggregate root --- frontend/src/entities/index.ts | 1 - frontend/src/entities/workflow-run/README.md | 9 +++++++++ frontend/src/entities/workflow-run/index.ts | 3 +-- .../src/entities/workflow-run/model/index.ts | 1 - .../src/entities/workflow-run/model/types.ts | 3 --- .../entities/workflow-run/service/index.ts | 2 +- .../service/workflow-run-service.ts | 19 +++++++++++-------- .../workflow-controller/controller.test.ts | 8 ++++---- .../workflow-controller/controller.ts | 4 ++-- 9 files changed, 28 insertions(+), 22 deletions(-) diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 1a440023..cbbd4940 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -80,7 +80,6 @@ export type { WorkflowRevision, WorkflowRun, WorkflowRunCharacterBinding, - WorkflowRunHandle, WorkflowRunKind, WorkflowRunRepository, WorkflowRunService, diff --git a/frontend/src/entities/workflow-run/README.md b/frontend/src/entities/workflow-run/README.md index ecd8ca03..ca47cb40 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -2,6 +2,15 @@ WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模式,也不表示单个生成任务。 +## 领域对象与快照 + +- `WorkflowRun` 是可执行的领域对象,也是角色制作任务的聚合根。它提供开始、确认候选、追加 + 动作、重做和发布等状态转换方法。 +- `WorkflowRunSnapshot` 是 `WorkflowRun` 在某一时刻的纯数据表示,只用于页面渲染、接口传输 + 和 Repository 持久化,不提供业务操作方法。 +- `WorkflowRunService` 只负责创建或恢复 `WorkflowRun`;`WorkflowRunRepository` 只读写 + `WorkflowRunSnapshot`。两者不能把快照重新当成完整的 Run。 + ## 数据关系 ```text diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 09446ea5..6c658986 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -18,7 +18,6 @@ export type { WorkflowGenerationRef, WorkflowGenerationRole, WorkflowRunKind, - WorkflowRun, WorkflowRevision, WorkflowRunSnapshot, WorkflowRunCharacterBinding, @@ -43,6 +42,6 @@ export type { CharacterCandidateConfirmationApis, CreateWorkflowRunServiceOptions, PublishActionResult, - WorkflowRunHandle, + WorkflowRun, WorkflowRunService, } from './service' diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts index 290df361..56109c1c 100644 --- a/frontend/src/entities/workflow-run/model/index.ts +++ b/frontend/src/entities/workflow-run/model/index.ts @@ -17,7 +17,6 @@ export type { WorkflowCharacterOrigin, WorkflowGenerationRef, WorkflowGenerationRole, - WorkflowRun, WorkflowRunKind, WorkflowRevision, WorkflowRunSnapshot, diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts index a2c67bc3..ae82da0f 100644 --- a/frontend/src/entities/workflow-run/model/types.ts +++ b/frontend/src/entities/workflow-run/model/types.ts @@ -123,9 +123,6 @@ export interface WorkflowRunSnapshot { updatedAt: string } -/** 页面读取的 WorkflowRun 数据就是可持久化快照;命令方法由 WorkflowRunHandle 提供。 */ -export type WorkflowRun = WorkflowRunSnapshot - /** 新建角色任务;新增动作必须追加到这个角色已经存在的 Run。 */ export interface CreateWorkflowRunInput { projectId: string diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts index 0e2387ab..a0ea6a99 100644 --- a/frontend/src/entities/workflow-run/service/index.ts +++ b/frontend/src/entities/workflow-run/service/index.ts @@ -13,6 +13,6 @@ export type { CharacterCandidateConfirmationApis, CreateWorkflowRunServiceOptions, PublishActionResult, - WorkflowRunHandle, + WorkflowRun, WorkflowRunService, } from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts index 82c4a8bc..4bc0c1e1 100644 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ b/frontend/src/entities/workflow-run/service/workflow-run-service.ts @@ -66,10 +66,13 @@ export interface PublishActionResult { } /** - * 绑定具体 Run 的运行对象。页面持有这个对象即可,不再同时传递 Service 和 runId。 + * 一个可执行的 WorkflowRun 领域对象。 + * + * 它是角色制作任务的聚合根,负责维护运行状态并执行状态转换。页面持有这个对象即可, + * 不再同时传递 Service 和 runId;Repository 只保存它通过 `snapshot()` 暴露的纯数据快照。 * `snapshot()` 返回可渲染数据;`save()` 是显式持久化边界。 */ -export interface WorkflowRunHandle { +export interface WorkflowRun { readonly id: string snapshot(): WorkflowRunSnapshot save(): Promise @@ -96,11 +99,11 @@ export interface WorkflowRunHandle { /** Service 只负责创建或恢复运行实例。 */ export interface WorkflowRunService { - create(input: CreateWorkflowRunInput): Promise - get(runId: WorkflowRunSnapshot['id']): Promise - getByCharacter(binding: WorkflowRunCharacterBinding): Promise + create(input: CreateWorkflowRunInput): Promise + get(runId: WorkflowRunSnapshot['id']): Promise + getByCharacter(binding: WorkflowRunCharacterBinding): Promise /** 根据 Character 找到原 Run 并追加动作;找不到时失败,绝不偷偷创建第二个 Run。 */ - appendAction(input: AppendWorkflowActionInput): Promise + appendAction(input: AppendWorkflowActionInput): Promise } export interface CreateWorkflowRunServiceOptions { @@ -122,7 +125,7 @@ export function createWorkflowRunService( const createId = options.createId ?? createRandomId const now = options.now ?? (() => new Date().toISOString()) - function bind(initial: WorkflowRunSnapshot): WorkflowRunHandle { + function bind(initial: WorkflowRunSnapshot): WorkflowRun { let state = structuredClone(initial) const current = (): WorkflowRunSnapshot => structuredClone(state) @@ -287,7 +290,7 @@ export function createWorkflowRunService( return { snapshot: current(), candidateTaskIds: taskIds, candidates } } - const run: WorkflowRunHandle = { + const run: WorkflowRun = { id: state.id, snapshot: current, save: persist, diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index c019afe5..54fb4fd1 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import type { WorkflowRunHandle, WorkflowRunService, WorkflowRunSnapshot } from '@/entities' +import type { WorkflowRun, WorkflowRunService, WorkflowRunSnapshot } from '@/entities' import { createWorkflowController } from './controller' function snapshot(status: WorkflowRunSnapshot['status'] = 'completed'): WorkflowRunSnapshot { @@ -46,7 +46,7 @@ function snapshot(status: WorkflowRunSnapshot['status'] = 'completed'): Workflow } } -function handle(state = snapshot()): WorkflowRunHandle { +function createRun(state = snapshot()): WorkflowRun { let current = structuredClone(state) return { id: current.id, @@ -72,7 +72,7 @@ function handle(state = snapshot()): WorkflowRunHandle { } } -function service(run = handle()): WorkflowRunService { +function service(run = createRun()): WorkflowRunService { return { create: vi.fn(async () => run), get: vi.fn(async () => run), @@ -97,7 +97,7 @@ describe('WorkflowController', () => { }) it('persists local interrupt and restart mutations exactly once', async () => { - const run = handle(snapshot('active')) + const run = createRun(snapshot('active')) const controller = createWorkflowController({ workflowService: service(run) }) await expect(controller.interrupt('run-1')).resolves.toMatchObject({ status: 'interrupted' }) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index df690237..a9e6ccbd 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -7,8 +7,8 @@ import type { ConfigureWorkflowActionInput, CreateWorkflowRunInput, PublishActionResult, + WorkflowRun, WorkflowRunCharacterBinding, - WorkflowRunHandle, WorkflowRunService, WorkflowRunSnapshot, } from '@/entities' @@ -50,7 +50,7 @@ export interface CreateWorkflowControllerOptions { export function createWorkflowController({ workflowService, }: CreateWorkflowControllerOptions): WorkflowController { - async function requireRun(runId: string): Promise { + async function requireRun(runId: string): Promise { const run = await workflowService.get(runId) if (!run) throw new Error(`WorkflowRun 不存在:${runId}`) return run From 09e02418b621e67fe8ad6c145ffbb1bb50d285ef Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:27:33 +0800 Subject: [PATCH 26/27] refactor(frontend): align controller with current workflow run --- frontend/src/entities/generation/index.ts | 128 ++- frontend/src/entities/index.ts | 76 +- frontend/src/entities/workflow-run/README.md | 102 +- .../src/entities/workflow-run/constants.ts | 16 + frontend/src/entities/workflow-run/index.ts | 256 ++++- .../entities/workflow-run/model/constants.ts | 41 - .../src/entities/workflow-run/model/index.ts | 29 - .../entities/workflow-run/model/selectors.ts | 22 - .../src/entities/workflow-run/model/types.ts | 159 --- .../entities/workflow-run/service/index.ts | 18 - .../service/workflow-run-service.test.ts | 631 ------------ .../service/workflow-run-service.ts | 950 ------------------ .../src/entities/workflow-run/store.test.ts | 449 +++++++++ frontend/src/entities/workflow-run/store.ts | 522 ++++++++++ .../src/entities/workflow-run/store/index.ts | 12 - .../store/workflow-run-store.test.ts | 232 ----- .../workflow-run/store/workflow-run-store.ts | 475 --------- .../features/character-setup/index.test.ts | 10 - .../src/features/character-setup/index.ts | 4 +- frontend/src/features/publish/index.test.ts | 142 +++ frontend/src/features/publish/index.ts | 91 ++ .../features/workflow-controller/README.md | 34 +- .../action-generation-task.ts | 249 +++++ .../character-template-task.ts | 464 +++++++++ .../workflow-controller/controller.test.ts | 943 +++++++++++++++-- .../workflow-controller/controller.ts | 564 +++++++++-- .../src/features/workflow-controller/index.ts | 6 +- .../store-invariants.test.ts | 243 +++++ .../workflow-run.integration.test.ts | 102 ++ .../workflow-state.test.ts | 421 ++++++++ .../workflow-controller/workflow-state.ts | 657 ++++++++++++ frontend/src/pages/home/index.tsx | 7 +- 32 files changed, 5043 insertions(+), 3012 deletions(-) create mode 100644 frontend/src/entities/workflow-run/constants.ts delete mode 100644 frontend/src/entities/workflow-run/model/constants.ts delete mode 100644 frontend/src/entities/workflow-run/model/index.ts delete mode 100644 frontend/src/entities/workflow-run/model/selectors.ts delete mode 100644 frontend/src/entities/workflow-run/model/types.ts delete mode 100644 frontend/src/entities/workflow-run/service/index.ts delete mode 100644 frontend/src/entities/workflow-run/service/workflow-run-service.test.ts delete mode 100644 frontend/src/entities/workflow-run/service/workflow-run-service.ts create mode 100644 frontend/src/entities/workflow-run/store.test.ts create mode 100644 frontend/src/entities/workflow-run/store.ts delete mode 100644 frontend/src/entities/workflow-run/store/index.ts delete mode 100644 frontend/src/entities/workflow-run/store/workflow-run-store.test.ts delete mode 100644 frontend/src/entities/workflow-run/store/workflow-run-store.ts delete mode 100644 frontend/src/features/character-setup/index.test.ts create mode 100644 frontend/src/features/publish/index.test.ts create mode 100644 frontend/src/features/publish/index.ts create mode 100644 frontend/src/features/workflow-controller/action-generation-task.ts create mode 100644 frontend/src/features/workflow-controller/character-template-task.ts create mode 100644 frontend/src/features/workflow-controller/store-invariants.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-run.integration.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.test.ts create mode 100644 frontend/src/features/workflow-controller/workflow-state.ts diff --git a/frontend/src/entities/generation/index.ts b/frontend/src/entities/generation/index.ts index d6d716c2..16b47030 100644 --- a/frontend/src/entities/generation/index.ts +++ b/frontend/src/entities/generation/index.ts @@ -4,29 +4,17 @@ import type { MediaReference } from '../media' /** * Generation 是业务数据,不是「调用图片生成能力」。 * 前端只创建 generation 并订阅它的状态;真正调用模型的是后端,前端不接触那一层。 - * - * 后端只有 GenerationTask 一个实体,generation 与 task 指同一条记录; - * `/generation/tasks/{task_id}` 里的 tasks 只是路径段,前端不为它另立实体。 - */ - -/** - * 后端 GenerationTask.status 是单次生成任务状态,不等于 WorkflowRun 或卡片的状态。 - * 一个 Run/Step 可以引用零个、一个或多个 GenerationTask。 - * 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' -/** 完整动作进入 WorkflowRun 审核与发布前必须恰好包含的帧数。 */ +/** 完整动作默认生成帧数;首帧生成仍固定为 1 帧。 */ export const COMPLETE_ANIMATION_FRAME_COUNT = 32 +/** 后端单次生成任务的生命周期。 */ +export type GenerationTaskStatus = 'pending' | 'running' | 'completed' | 'failed' + interface GenerationInputBase { projectId: string /** 可选参考媒体;没有参考图时传空数组。 */ @@ -38,6 +26,10 @@ export interface CharacterTemplateGenerationInput extends GenerationInputBase { type: 'character_template' /** 已由手动输入或 Quick Start 整理好的角色提示词。 */ prompt: string + /** 项目约束的精灵图宽度,提交生成时传给后端做尺寸校验。 */ + spriteWidth: number + /** 项目约束的精灵图高度,提交生成时传给后端做尺寸校验。 */ + spriteHeight: number } /** 指定角色造型下的动作首帧生成;不能只绑定 Character。 */ @@ -77,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 = @@ -101,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 cbbd4940..ce2e85ae 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -1,7 +1,8 @@ -/** Entity 层的唯一公开入口。外部模块不得绕过这里访问 Entity 内部文件。 */ +/** entities 唯一公开入口。外部不得绕过本文件访问内部文件。 */ -/* 项目:视角、朝向、精灵尺寸与画风等全局约束。 */ -export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, projectApis } from './project' +/* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ +export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT } from './project' +export { projectApis } from './project' export type { CharacterPerspective, CreateProjectInput, @@ -11,8 +12,7 @@ export type { ProjectPageQuery, } from './project' -/* 角色:角色、造型、动作和帧组成同一棵资产树。 */ -export { characterApis } from './character' +/* 角色 —— 资产本体;造型、动作、帧都在这棵树里 */ export type { Action, ActionType, @@ -22,11 +22,16 @@ export type { Frame, Outfit, } from './character' +export { characterApis } from './character' +/* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' -/* 生成:后端 GenerationTask 的前端领域契约。 */ -export { COMPLETE_ANIMATION_FRAME_COUNT } from './generation' +/* 生成 —— 业务数据,不是「调用生成能力」 */ +export { + COMPLETE_ANIMATION_FRAME_COUNT, + parseCharacterTemplateGenerationResult, +} from './generation' export type { CharacterTemplateGenerationInput, CharacterTemplateGenerationResult, @@ -34,6 +39,7 @@ export type { CompleteAnimationGenerationResult, FirstFrameGenerationInput, FirstFrameGenerationResult, + GeneratedAnimationFrame, GeneratedImage, Generation, GenerationApis, @@ -42,51 +48,31 @@ export type { GenerationResult, GenerationResultFor, GenerationType, - TaskStatus, + GenerationTaskStatus, } from './generation' +/* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' -/* WorkflowRun:一个 Character 的制作记录与可恢复执行能力。 */ -export { - ACTION_FIRST_FRAME_CANDIDATE_COUNT, - CHARACTER_CANDIDATE_COUNT, - createWorkflowRunRepository, - createWorkflowRunService, - findActionStepByActionId, - isWorkflowRunSnapshot, - WORKFLOW_RUN_STORAGE_KEY, - WORKFLOW_RUN_STORAGE_VERSION, - WORKFLOW_STEP_ORDERS, -} from './workflow-run' +/* 工作流 —— 节点与运行状态都由前端管理 */ +export { createWorkflowRunStore, WORKFLOW_STEP_ORDER } from './workflow-run' export type { - AcceptUploadedCharacterTemplateInput, - ActionFirstFrameCandidateBatch, - ActionReviewResult, - AppendWorkflowActionInput, - BuiltinWorkflowRunSource, - CharacterCandidateBatch, - CharacterCandidateConfirmationApis, - ConfigureWorkflowActionInput, + ActionGenerationWorkflowStep, + CharacterSetupStepInput, + CharacterSetupWorkflowStep, + CharacterTemplateWorkflowStep, CreateWorkflowRunInput, - CreateWorkflowRunRepositoryOptions, - CreateWorkflowRunServiceOptions, - PublishActionResult, - WorkflowActionInput, - WorkflowCharacterInput, - WorkflowCharacterOrigin, - WorkflowGenerationRef, - WorkflowGenerationRole, - WorkflowRevision, - WorkflowRun, - WorkflowRunCharacterBinding, - WorkflowRunKind, - WorkflowRunRepository, - WorkflowRunService, - WorkflowRunSnapshot, - WorkflowRunStatus, + CreateWorkflowRunStoreOptions, + ExportStatus, + GenerationStatus, + WorkflowDriver, WorkflowStep, - WorkflowStepPhase, WorkflowStepStatus, WorkflowStepType, + 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 index ca47cb40..cb3809ad 100644 --- a/frontend/src/entities/workflow-run/README.md +++ b/frontend/src/entities/workflow-run/README.md @@ -1,99 +1,11 @@ # WorkflowRun -WorkflowRun 表示从一个根任务开始的一次执行,不表示页面模式,也不表示单个生成任务。 +本目录保存 Controller 使用的 WorkflowRun 数据模型和本地存储。这里沿用当前工作区的最小概念: +`WorkflowRun + WorkflowRunStore`,不增加 Snapshot 或 Handle。 -## 领域对象与快照 +- `WorkflowRun` 是一条角色制作流程,持有 Revision、Step、角色关联和当前状态。 +- `WorkflowRevision` 只表达从历史步骤重新执行形成的新版本。 +- `WorkflowRunStore` 直接读写 WorkflowRun,并保证同一 Character 只绑定一条 Run。 +- 新增动作在原 Run 当前 Revision 末尾追加动作生成和审核步骤。 -- `WorkflowRun` 是可执行的领域对象,也是角色制作任务的聚合根。它提供开始、确认候选、追加 - 动作、重做和发布等状态转换方法。 -- `WorkflowRunSnapshot` 是 `WorkflowRun` 在某一时刻的纯数据表示,只用于页面渲染、接口传输 - 和 Repository 持久化,不提供业务操作方法。 -- `WorkflowRunService` 只负责创建或恢复 `WorkflowRun`;`WorkflowRunRepository` 只读写 - `WorkflowRunSnapshot`。两者不能把快照重新当成完整的 Run。 - -## 数据关系 - -```text -WorkflowDefinition(未来由 Workflow Editor 管理) - └─ WorkflowRun(一个 Character 的完整制作记录) - └─ WorkflowRevision(从某张卡片重做形成的执行分支) - ├─ Character Step(只出现一次) - ├─ Action Step:待机 - ├─ Action Step:行走 - └─ Action Step:其他新增动作 -``` - -Quick Start 和 Workflow Editor 可以用不同界面推进同一种 Run,所以核心模型不保存 -`ai/manual driver`。一个 Character 只绑定一个 Run,首个动作和以后新增的动作都追加到这里。 -当前后端仍把动作放在默认 Outfit 下,但 Outfit 只是内部兼容结构,不参与 Run 定位,也不进入 -用户操作。 - -## Step 与卡片 - -当前内置流程只有 `character` 和 `action` 两种 Step,但 Action Step 可以有多个。角色卡片内部 -依次经历“生成四张候选、选择一张”;每个动作卡片分别经历“配置、生成四张首帧、选择首帧、 -生成动画、审核、导出”。每个动作的输入和 GenerationTask 引用都按 Action Step ID 隔离。 - -新 Run 只预建 Character Step。角色确认后立即保存并完成,用户可以直接退出;Quick Start -如果要连续生成首个动作,也必须调用与后续动作相同的 `appendAction`,不能依赖一张预埋的动作 -卡片。同一时间最多有一个活动 Action Step,当前动作完成或失败后才能追加下一个。 - -## Revision 解决什么 - -Quick Start 默认只有一个初始 Revision。Workflow Editor 从历史卡片重做时,在同一个 Run -中追加新 Revision:目标卡片之前已通过的结果可以复用,目标卡片及其后续内容被重置,旧 -Revision 保持只读。异步请求返回时还要核对发起它的 Revision,避免旧分支的晚到结果污染 -当前分支。 - -WorkflowRevision 只描述 Action Step 的执行分支,不是 GenerationTask,也不是 -WorkflowDefinition 的定义版本。重新生成角色图必须新建 Character 和 WorkflowRun,不能在原 -Run 内覆盖角色;给现有 Character 新增动作,则在当前 Revision 追加 Action Step。 - -`WorkflowRunSnapshot.version` 是另一件事:它只是持久化乐观锁。每次成功保存加一,旧页面用 -过期版本保存时会收到冲突错误,不能覆盖较新的 Run。代码和讲解中不要把这个数字与 -`WorkflowRevision` 混称为同一种 Revision。 - -## 与后端生成执行的边界 - -WorkflowRun 只保存用户创作意图、当前卡片状态以及 `GenerationTask` ID,用这些稳定业务数据 -完成页面恢复。后端如何整理提示词、选择模型和执行生成任务不属于 WorkflowRun 的职责。 - -`WorkflowCharacterInput.prompt` 和 `WorkflowActionInput.prompt` 虽然沿用 API 中的字段名,表达的 -都是用户原始创作意图,不等同于模型供应商最终收到的提示词。最终提示词和内部执行过程属于 -后端 GenerationTask 的实现细节,不能写进前端快照。 - -后端以后即使更换提示词整理、模型选择或任务执行方案,也不需要迁移 WorkflowRun;刷新页面时, -前端只根据 GenerationTask ID 恢复任务。 - -## Step ID 与 Action ID - -`stepId` 是前端 WorkflowRun 卡片 ID;正式 `actionId` 是后端保存动作后返回的资产 ID。两者不能 -共用。动作发布前 `WorkflowActionInput.actionId` 为 null;保存成功后,前端只把后端响应中的 -正式 ID 写回当前 Step。`findActionStepByActionId` 负责从正式动作反查当前 Revision 的卡片。 - -## 职责 - -- `model` 不依赖页面、localStorage 或 SSE。 -- `store` 只负责异步持久化,不提供页面订阅。 -- 当前 `LocalStorageWorkflowRunRepository` 是过渡实现;页面和业务层只依赖异步 Repository, - 后续换成 HTTP 实现不需要改调用方式。 -- `getByCharacter(projectId + characterId)` 精确定位 Character 的唯一 Run;Repository 保存时 - 拒绝第二条 Run 绑定同一个 Character。 -- `service.create` 只创建角色 Run;`appendAction` 根据 `projectId + characterId` 找到原 Run - 并追加动作,找不到时明确失败,绝不偷偷创建第二个 Run。 -- 绑定 Run 的状态修改由 Controller 在页面命令成功后保存;远程任务 ID 等恢复检查点由 Run - 自己及时保存。 -- Generation SSE 继续由 Generation Entity 负责。 -- 上传母版用 `characterOrigin: uploaded` 表达,不伪造 GenerationTask;上传成功后立即创建并 - 绑定正式 Character/Outfit。 -- 提示词整理、模型选择和内部执行过程由后端 Generation 模块负责。 -- 本地快照格式已升到 v8;旧的 Outfit 定位和前端 Action ID 结构不会被误水合。 - -## 动作生成验收 - -每次生成动作首帧和完整动画时,Service 都必须把已确认的角色母版放入 -`referenceMedia`。母版优先取当前 Outfit 的 `previewUrl`,没有造型预览时才回退到 Character -的 `referenceImageUrl`;两者都不存在就中止请求,不能发送空母版。 - -完整动画由后端负责生成和补帧。WorkflowRun 不修改帧数组,只在结果恰好包含 32 张有效帧时 -进入审核;少帧或多帧都会把当前 Action Step 标记为失败,禁止继续发布。 +后端 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 6c658986..9eae037e 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -1,47 +1,211 @@ -/** WorkflowRun Entity 的唯一公开入口。 */ - -export { - ACTION_FIRST_FRAME_CANDIDATE_COUNT, - CHARACTER_CANDIDATE_COUNT, - findActionStepByActionId, - WORKFLOW_STEP_ORDERS, -} from './model' -export type { - AppendWorkflowActionInput, - AcceptUploadedCharacterTemplateInput, - BuiltinWorkflowRunSource, - ConfigureWorkflowActionInput, - CreateWorkflowRunInput, - WorkflowActionInput, - WorkflowCharacterInput, - WorkflowCharacterOrigin, - WorkflowGenerationRef, - WorkflowGenerationRole, - WorkflowRunKind, - WorkflowRevision, - WorkflowRunSnapshot, - WorkflowRunCharacterBinding, - WorkflowRunStatus, - WorkflowStep, - WorkflowStepPhase, - WorkflowStepStatus, +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 = (typeof WORKFLOW_DRIVERS)[number] + +/** 创建 WorkflowRun 时要完成的用户意图。 */ +export type WorkflowRunPurpose = (typeof WORKFLOW_PURPOSES)[number] + +/** + * 流程步骤类型的唯一标准顺序;它不是后端 Workflow 或 Execution 定义。 + * 某个 Revision 已进入执行线的步骤顺序,由 WorkflowRevision.steps 的数组位置表达。 + */ +/** 前端流程步骤类型,与 WORKFLOW_STEP_ORDER 的成员保持一致。 */ +export type WorkflowStepType = (typeof WORKFLOW_STEP_ORDER)[number] + +/** + * 步骤的可用性和执行结果;不直接复用后端任务状态。 + * locked/available 表示尚未执行,active 表示当前页面阶段,passed/failed 表示结果。 + */ +export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] + +/** + * 单个版本的生命周期。 + * abandoned 表示停止沿用但仍保留为历史。 + */ +export type WorkflowRevisionStatus = (typeof WORKFLOW_REVISION_STATUSES)[number] + +/** + * 整次流程的汇总状态。 + * interrupted 只表示用户主动停止自动推进:历史仍保留且可只读查看,它不等于 failed 或 completed。 + * 后端 Generation 是否真正停止是独立问题;前端中断只停止自动推进与订阅。 + */ +export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] + +/** 当前版本在生成阶段的汇总状态;素材准备期间为 not_started。 */ +export type GenerationStatus = (typeof GENERATION_STATUSES)[number] + +/** 当前版本在导出阶段的汇总状态。 */ +export type ExportStatus = (typeof EXPORT_STATUSES)[number] + +interface WorkflowStepBase { + /** 只用于编排和页面定位,不作为业务 ID 发送给后端。 */ + id: string + status: WorkflowStepStatus + /** + * 本步骤已提交、结果尚未写回 output 的生成任务 ID;没有在途任务时为 null。 + * 它由前端随 WorkflowRun 一起维护,据此查回在途任务的状态,因而不会在同一次 + * 前端运行中重复发起生成。是否写入浏览器存储属于前端实现,不形成后端契约。 + * 任务本身不认识步骤,反向关联不存在。 + */ + 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, -} from './model' -export { - createWorkflowRunRepository, - isWorkflowRunSnapshot, - WORKFLOW_RUN_STORAGE_KEY, - WORKFLOW_RUN_STORAGE_VERSION, -} from './store' -export type { CreateWorkflowRunRepositoryOptions, WorkflowRunRepository } from './store' -export { createWorkflowRunService } from './service' -export type { - ActionFirstFrameCandidateBatch, - ActionReviewResult, - CharacterCandidateBatch, - CharacterCandidateConfirmationApis, - CreateWorkflowRunServiceOptions, - PublishActionResult, - WorkflowRun, - WorkflowRunService, -} from './service' + 'character-setup' | 'character-template' | 'action-generation' +> + +interface RemainingWorkflowStep extends WorkflowStepBase { + type: RemainingWorkflowStepType + /** 候选确认与审核的具体输入输出在对应纵切中继续收窄。 */ + input: unknown + output: unknown +} + +/** + * 一个 Revision 中已经进入执行线的流程步骤。 + * 前两个执行步骤已冻结输入输出;后续三步进入对应纵切时再收窄, + * 不提前猜页面尚未产生的数据形状。 + */ +export type WorkflowStep = + | CharacterSetupWorkflowStep + | CharacterTemplateWorkflowStep + | ActionGenerationWorkflowStep + | RemainingWorkflowStep + +/** + * 一次页面执行版本;当前版本会推进,从旧步骤重开则追加新版本。 + * + * 从历史步骤重开时,旧 Revision 保留为只读记录,新 Revision 引用它的重开步骤。 + * 新执行线中的下游步骤会清空并重新锁定,不能作为新生成的参考依据。 + */ +export interface WorkflowRevision { + id: string + /** 首次创建的版本没有来源,因此为 null。 */ + basedOnRevisionId: string | null + /** 在来源版本中选择的重启步骤 ID;非重启创建的版本为 null。 */ + restartStepId: string | null + status: WorkflowRevisionStatus + /** + * 当前版本先保存三个角色步骤,再保存一组或多组动作生成/审核步骤; + * 数组位置是步骤顺序及动作与审核配对关系的唯一来源。 + * 完整步骤类型顺序以 WORKFLOW_STEP_ORDER 为准。 + */ + steps: WorkflowStep[] + generationStatus: GenerationStatus + exportStatus: ExportStatus + createdAt: string +} + +/** + * 一次由前端推进的页面流程。 + * 步骤推进和运行状态都由前端管理;后端不读取、不推进、也不持久化 WorkflowRun。 + * 后端只处理生成任务,并在用户最终确认时持久化角色与动作资产。 + */ +export interface WorkflowRun { + id: string + projectId: string + /** 已关联的 Character ID;角色尚未创建或确认时为 null。 */ + characterId: string | null + /** 已有角色加动作时的目标造型;新建角色时为 null。 */ + outfitId: string | null + /** 建立这条 Run 时的根意图;后续追加动作不会把 create_character 改写为 add_action。 */ + purpose: WorkflowRunPurpose + driver: WorkflowDriver + status: WorkflowRunStatus + /** 当前可编辑版本 ID;必须能在 revisions 中找到。 */ + currentRevisionId: string + /** 按创建顺序保存的全部版本;历史版本保留用于只读查看和重启。 */ + revisions: WorkflowRevision[] + /** Quick Start 的规范化提示词;空白输入或手动模式无提示词时为 null。 */ + prompt: string | null +} + +/** 两种入口共享的创建字段。 */ +interface CreateWorkflowRunInputBase { + projectId: string + driver: WorkflowDriver + /** Quick Start 的自然语言需求;提交时去除首尾空白,空字符串按 null 保存。 */ + prompt?: string +} + +/** + * 创建 WorkflowRun 的输入。 + * add_action 分支把已有角色、造型、母版和基准帧设为必填,避免创建无法恢复的半成品运行。 + */ +export type CreateWorkflowRunInput = CreateWorkflowRunInputBase & + ( + | { + purpose: 'create_character' + characterId?: never + outfitId?: never + characterTemplateUrl?: never + baseFrameUrls?: never + } + | { + purpose: 'add_action' + characterId: string + outfitId: string + characterTemplateUrl: string + baseFrameUrls: readonly string[] + } + ) + +export { createWorkflowRunStore } from './store' +export type { CreateWorkflowRunStoreOptions, WorkflowRunStore } from './store' diff --git a/frontend/src/entities/workflow-run/model/constants.ts b/frontend/src/entities/workflow-run/model/constants.ts deleted file mode 100644 index db95f6c4..00000000 --- a/frontend/src/entities/workflow-run/model/constants.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** WorkflowRun 使用的稳定业务词汇。 */ - -/** 一个 Run 绑定一张最终确认的角色图,并容纳这个角色图的全部动作。 */ -export const WORKFLOW_RUN_KINDS = ['character_action'] as const - -export const WORKFLOW_RUN_STATUSES = ['active', 'interrupted', 'completed', 'failed'] as const - -/** Step 与 Workflow Editor 中用户看到的卡片一一对应。 */ -export const WORKFLOW_STEP_TYPES = ['character', 'action'] as const - -export const WORKFLOW_STEP_STATUSES = ['locked', 'active', 'passed', 'failed'] as const - -/** - * phase 描述卡片内部正在做什么,不再把“生成”和“选择”伪装成两张卡片。 - * 不同类型的 Step 只能使用各自对应的 phase,校验规则在 Repository 中集中维护。 - */ -export const WORKFLOW_STEP_PHASES = [ - 'generating_character_candidates', - 'selecting_character', - 'configuring_action', - 'generating_action_candidates', - 'selecting_action_frame', - 'generating_animation', - 'reviewing_animation', - 'exporting_action', - 'completed', -] as const - -export const WORKFLOW_GENERATION_ROLES = [ - 'character_candidates', - 'action_frame_candidate', - 'animation', -] as const - -export const CHARACTER_CANDIDATE_COUNT = 4 -export const ACTION_FIRST_FRAME_CANDIDATE_COUNT = 4 - -/** 内置流程只预建角色卡片;角色确认后,所有动作都通过 appendAction 继续追加。 */ -export const WORKFLOW_STEP_ORDERS = { - character_action: ['character'], -} as const diff --git a/frontend/src/entities/workflow-run/model/index.ts b/frontend/src/entities/workflow-run/model/index.ts deleted file mode 100644 index 56109c1c..00000000 --- a/frontend/src/entities/workflow-run/model/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** WorkflowRun 的可序列化模型和内置流程模板。 */ - -export { - ACTION_FIRST_FRAME_CANDIDATE_COUNT, - CHARACTER_CANDIDATE_COUNT, - WORKFLOW_STEP_ORDERS, -} from './constants' -export { findActionStepByActionId } from './selectors' -export type { - AcceptUploadedCharacterTemplateInput, - AppendWorkflowActionInput, - BuiltinWorkflowRunSource, - ConfigureWorkflowActionInput, - CreateWorkflowRunInput, - WorkflowActionInput, - WorkflowCharacterInput, - WorkflowCharacterOrigin, - WorkflowGenerationRef, - WorkflowGenerationRole, - WorkflowRunKind, - WorkflowRevision, - WorkflowRunSnapshot, - WorkflowRunCharacterBinding, - WorkflowRunStatus, - WorkflowStep, - WorkflowStepPhase, - WorkflowStepStatus, - WorkflowStepType, -} from './types' diff --git a/frontend/src/entities/workflow-run/model/selectors.ts b/frontend/src/entities/workflow-run/model/selectors.ts deleted file mode 100644 index 9b0c7fbb..00000000 --- a/frontend/src/entities/workflow-run/model/selectors.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { WorkflowRunSnapshot, WorkflowStep } from './types' - -/** - * 用后端正式 Action ID 定位当前 Revision 中的动作卡片。 - * Playtest 和资产页面不需要理解 actionInputs 以 Step ID 为 key 的内部结构。 - */ -export function findActionStepByActionId( - run: WorkflowRunSnapshot, - actionId: string, -): WorkflowStep | null { - const normalizedActionId = actionId.trim() - if (!normalizedActionId) return null - - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - if (!revision) return null - const entry = Object.entries(revision.actionInputs).find( - ([, input]) => input.actionId === normalizedActionId, - ) - if (!entry) return null - - return revision.steps.find((step) => step.id === entry[0] && step.type === 'action') ?? null -} diff --git a/frontend/src/entities/workflow-run/model/types.ts b/frontend/src/entities/workflow-run/model/types.ts deleted file mode 100644 index ae82da0f..00000000 --- a/frontend/src/entities/workflow-run/model/types.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** WorkflowRun 的可序列化执行快照。 */ - -import type { ActionType } from '../../character' -import type { Generation } from '../../generation' -import type { MediaReference } from '../../media' -import { - WORKFLOW_GENERATION_ROLES, - WORKFLOW_RUN_KINDS, - WORKFLOW_RUN_STATUSES, - WORKFLOW_STEP_PHASES, - WORKFLOW_STEP_STATUSES, - WORKFLOW_STEP_TYPES, -} from './constants' - -export type WorkflowRunKind = (typeof WORKFLOW_RUN_KINDS)[number] -export type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number] -export type WorkflowStepType = (typeof WORKFLOW_STEP_TYPES)[number] -export type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number] -export type WorkflowStepPhase = (typeof WORKFLOW_STEP_PHASES)[number] -export type WorkflowGenerationRole = (typeof WORKFLOW_GENERATION_ROLES)[number] -export type WorkflowCharacterOrigin = 'generated' | 'uploaded' - -/** - * Step 对后端 GenerationTask 的引用。 - * 一个角色卡片对应一次四图生成;一个动作卡片可以对应四次首帧生成和一次动画生成。 - * 后端任务如何整理提示词和执行生成不进入前端快照。 - */ -export interface WorkflowGenerationRef { - taskId: Generation['id'] - role: WorkflowGenerationRole -} - -/** - * 一个 Step 就是编辑器中的一张卡片;phase 是卡片内部状态。 - * 这样展示层不需要把多个技术步骤重新拼装成一张卡片。 - */ -export interface WorkflowStep { - id: string - /** 内置流程使用稳定节点名;未来编辑器运行时保存 Definition 中的 nodeId。 */ - nodeId: string - type: WorkflowStepType - status: WorkflowStepStatus - phase: WorkflowStepPhase - generations: WorkflowGenerationRef[] - error: string | null -} - -/** - * 同一个根任务中的一次执行分支。 - * - * 初始 Revision 没有父级;Workflow Editor 从某张卡片重做时,追加一个指向当前 - * Revision 的新成员。旧 Revision 不再修改,新 Revision 复用目标卡片之前已经通过的 - * 结果,并重置目标卡片及其后续卡片。 - */ -export interface WorkflowRevision { - id: string - parentRevisionId: string | null - /** 初始 Revision 为 null;后续 Revision 指向父 Revision 中触发重做的 Step。 */ - restartedFromStepId: WorkflowStep['id'] | null - steps: WorkflowStep[] - - /** 节点输入和产出属于执行分支,不能放在 Run 顶层覆盖旧 Revision。 */ - characterInput: WorkflowCharacterInput | null - /** 生成候选确认和用户上传是两条不同来源,不能用假的 GenerationTask 混在一起。 */ - characterOrigin: WorkflowCharacterOrigin | null - characterId: string | null - /** 当前后端 character_data 仍需要默认 Outfit;它只是兼容字段,不参与 Run 的业务身份。 */ - outfitId: string | null - characterSelectedAt: string | null - /** 每个动作卡片保存自己的输入;key 是当前 Revision 内的 Action Step ID。 */ - actionInputs: Record - - createdAt: string -} - -/** 当前 PR 支持的内置流程来源。Workflow Editor 后续会扩展 definition 来源。 */ -export interface BuiltinWorkflowRunSource { - type: 'builtin' - key: WorkflowRunKind - rootNodeId: string -} - -export interface WorkflowCharacterInput { - /** 用户的角色创作意图;后端生成模块负责转换为实际模型输入。 */ - prompt: string - referenceMedia: readonly MediaReference[] - /** 生成角色候选时必须沿用项目的精灵尺寸,刷新恢复后也不能丢失。 */ - spriteWidth: number - spriteHeight: number -} - -export interface WorkflowActionInput { - /** 正式动作资产 ID 只接受后端保存结果;动作尚未发布时为 null。 */ - actionId: string | null - name: string - type: ActionType - /** 用户的动作描述;不是直接发送给模型供应商的最终提示词。 */ - prompt: string | null - fps: number -} - -/** - * 一次根任务的执行数据。 - * - * 这里故意没有 driver:自动或手动推进属于界面行为。Revision 只表达 Workflow Editor - * 在同一个根任务中“从某张卡片重做”的执行分支,不表达 GenerationTask,也不取代 - * WorkflowDefinition 的定义版本。 - */ -export interface WorkflowRunSnapshot { - id: string - projectId: string - /** - * 持久化并发版本,与下面的 WorkflowRevision 不是同一个概念。 - * Repository 每次成功保存后加一,用于阻止旧页面覆盖较新的运行快照。 - */ - version: number - source: BuiltinWorkflowRunSource - status: WorkflowRunStatus - currentRevisionId: WorkflowRevision['id'] - revisions: WorkflowRevision[] - - createdAt: string - updatedAt: string -} - -/** 新建角色任务;新增动作必须追加到这个角色已经存在的 Run。 */ -export interface CreateWorkflowRunInput { - projectId: string - characterPrompt: string - referenceMedia?: readonly MediaReference[] - /** 未传时兼容旧入口使用 256;真实页面必须传项目精灵尺寸。 */ - spriteSize?: { width: number; height: number } -} - -/** 配置当前活动的动作卡片。 */ -export interface ConfigureWorkflowActionInput { - actionName: string - actionType: ActionType - actionPrompt?: string | null - fps: number -} - -/** 按 Character 定位原 Run,并在其中追加动作卡片。 */ -export interface AppendWorkflowActionInput extends ConfigureWorkflowActionInput { - projectId: string - characterId: string -} - -export interface WorkflowRunCharacterBinding { - projectId: string - characterId: string -} - -export interface AcceptUploadedCharacterTemplateInput { - referenceMedia: MediaReference - /** 为空时沿用创建 Run 时的角色描述。 */ - description?: string | null - name?: string | null -} diff --git a/frontend/src/entities/workflow-run/service/index.ts b/frontend/src/entities/workflow-run/service/index.ts deleted file mode 100644 index a0ea6a99..00000000 --- a/frontend/src/entities/workflow-run/service/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * WorkflowRun 可执行用例的子目录入口。 - * - * model 只定义数据,store 只管快照,service 负责组合真实 Character/Generation - * 端口完成角色和动作任务。页面应调用这些用例,不自行改写 Run。 - */ - -export { createWorkflowRunService } from './workflow-run-service' -export type { - ActionFirstFrameCandidateBatch, - ActionReviewResult, - CharacterCandidateBatch, - CharacterCandidateConfirmationApis, - CreateWorkflowRunServiceOptions, - PublishActionResult, - WorkflowRun, - WorkflowRunService, -} from './workflow-run-service' diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts deleted file mode 100644 index af51c278..00000000 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.test.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import type { Character, CharacterApis } from '../../character' -import { - COMPLETE_ANIMATION_FRAME_COUNT, - type Generation, - type GenerationApis, - type GenerationEvent, - type GenerationInput, -} from '../../generation' -import type { MediaReference } from '../../media' -import { findActionStepByActionId, type WorkflowRunSnapshot } from '../model' -import { createWorkflowRunRepository } from '../store' -import { - createWorkflowRunService, - type ActionFirstFrameCandidateBatch, - type CharacterCandidateBatch, - type CharacterCandidateConfirmationApis, -} from './workflow-run-service' - -function createCharacter(): Character { - return { - id: 'character-1', - projectId: 'project-1', - name: '守夜人', - description: '一位像素风守夜人', - referenceImageUrl: 'candidate-2.png', - dataVersion: 1, - status: 1, - outfits: [ - { - id: 'outfit-1', - characterId: 'character-1', - name: '默认造型', - description: null, - previewUrl: 'candidate-2.png', - actions: [], - }, - ], - } -} - -function createGenerationApis() { - const tasks = new Map() - let nextId = 0 - const create = vi.fn(async (input: GenerationInput): Promise => { - const id = `generation-${++nextId}` - const result = - input.type === 'character_template' - ? { - type: 'character_template' as const, - images: [1, 2, 3, 4].map((index) => ({ url: `candidate-${index}.png` })), - } - : input.type === 'first_frame' - ? { type: 'first_frame' as const, image: { url: `first-frame-${id}.png` } } - : { - type: 'complete_animation' as const, - frames: Array.from({ length: COMPLETE_ANIMATION_FRAME_COUNT }, (_, index) => ({ - url: `frame-${index + 1}.png`, - })), - } - const task: Generation = { - id, - projectId: input.projectId, - type: input.type, - status: 'completed', - result, - error: null, - } - tasks.set(id, task) - return task - }) - const apis: GenerationApis = { - create, - async get(_projectId, id) { - const task = tasks.get(id) - if (!task) throw new Error('任务不存在') - return structuredClone(task) - }, - subscribe() { - return () => undefined - }, - } - return { apis, create, tasks } -} - -function createFixture() { - let id = 0 - let backendActionId = 0 - let timestamp = 0 - const repository = createWorkflowRunRepository({ storage: null }) - const generation = createGenerationApis() - let character = createCharacter() - const characterApis: CharacterApis = { - get: vi.fn(async () => structuredClone(character)), - listByProject: vi.fn(async () => ({ - items: [structuredClone(character)], - total: 1, - page: 1, - pageSize: 20, - })), - create: vi.fn(async () => structuredClone(character)), - update: vi.fn(async (next) => { - const existingActionIds = new Set( - character.outfits.flatMap((outfit) => outfit.actions.map((action) => action.id)), - ) - character = structuredClone(next) - for (const outfit of character.outfits) { - for (const action of outfit.actions) { - if (!existingActionIds.has(action.id)) action.id = `backend-action-${++backendActionId}` - } - } - return structuredClone(character) - }), - remove: vi.fn(async () => undefined), - } - const confirmSelection = vi.fn(async () => ({ - character: structuredClone(character), - outfitId: 'outfit-1', - })) - const candidateConfirmationApis: CharacterCandidateConfirmationApis = { confirmSelection } - const service = createWorkflowRunService({ - repository, - generationApis: generation.apis, - characterApis, - candidateConfirmationApis, - createId: () => `workflow-id-${++id}`, - now: () => `2026-08-05T01:00:${String(++timestamp).padStart(2, '0')}.000Z`, - }) - return { service, repository, generation, characterApis, confirmSelection } -} - -function currentSteps(snapshot: WorkflowRunSnapshot) { - return currentRevisionSnapshot(snapshot).steps -} - -function currentRevisionSnapshot(snapshot: WorkflowRunSnapshot) { - return snapshot.revisions.find((revision) => revision.id === snapshot.currentRevisionId)! -} - -describe('WorkflowRun instance', () => { - it('completes after character selection and appends the first action to the same run', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '一位像素风守夜人', - }) - const runId = run.id - expect(currentSteps(run.snapshot()).map((step) => step.type)).toEqual(['character']) - - const characters = (await run.start()) as CharacterCandidateBatch - expect(characters.candidates).toHaveLength(4) - expect(currentSteps(characters.snapshot)[0]).toMatchObject({ - type: 'character', - status: 'active', - phase: 'selecting_character', - }) - expect(JSON.stringify(await fixture.repository.get(runId))).not.toContain('candidate-1.png') - - await run.confirmCharacter('candidate-2.png') - expect(run.snapshot()).toMatchObject({ id: runId, status: 'completed' }) - expect(currentRevisionSnapshot(run.snapshot())).toMatchObject({ - characterId: 'character-1', - outfitId: 'outfit-1', - }) - expect(currentSteps(run.snapshot())).toHaveLength(1) - - const actionRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '向前行走', - actionType: 'walk', - actionPrompt: '轻快地向前行走', - fps: 12, - }) - const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch - expect(firstFrames.candidates).toHaveLength(4) - expect(firstFrames.snapshot.id).toBe(runId) - expect(currentSteps(firstFrames.snapshot)[1]).toMatchObject({ - phase: 'selecting_action_frame', - }) - - await actionRun.confirmActionFirstFrame(firstFrames.candidates[1]!) - expect(currentSteps(actionRun.snapshot())[1]).toMatchObject({ phase: 'reviewing_animation' }) - const published = await actionRun.approveAction() - - expect(published.snapshot).toMatchObject({ id: runId, status: 'completed' }) - expect(currentSteps(published.snapshot)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: 'character', status: 'passed', phase: 'completed' }), - expect.objectContaining({ type: 'action', status: 'passed', phase: 'completed' }), - ]), - ) - const publishedAction = published.character.outfits[0]?.actions[0] - expect(publishedAction).toMatchObject({ - id: 'backend-action-1', - name: '向前行走', - type: 'walk', - loop: true, - fps: 12, - frameCount: COMPLETE_ANIMATION_FRAME_COUNT, - }) - expect(publishedAction?.frames).toHaveLength(COMPLETE_ANIMATION_FRAME_COUNT) - expect(publishedAction?.frames[0]).toMatchObject({ index: 0, imageUrl: 'frame-1.png' }) - expect(publishedAction?.frames.at(-1)).toMatchObject({ - index: COMPLETE_ANIMATION_FRAME_COUNT - 1, - imageUrl: `frame-${COMPLETE_ANIMATION_FRAME_COUNT}.png`, - }) - expect(published.actionId).toBe('backend-action-1') - expect( - currentRevisionSnapshot(published.snapshot).actionInputs[ - currentSteps(published.snapshot)[1]!.id - ], - ).toMatchObject({ actionId: 'backend-action-1' }) - expect(published.actionId).not.toBe(currentSteps(published.snapshot)[1]!.id) - expect(findActionStepByActionId(published.snapshot, published.actionId)?.id).toBe( - currentSteps(published.snapshot)[1]!.id, - ) - const actionGenerationInputs = fixture.generation.create.mock.calls - .map(([input]) => input) - .filter((input) => input.type === 'first_frame' || input.type === 'complete_animation') - expect(actionGenerationInputs).toHaveLength(5) - expect( - actionGenerationInputs.every((input) => - input.referenceMedia.includes('candidate-2.png' as MediaReference), - ), - ).toBe(true) - expect(fixture.generation.create).toHaveBeenCalledTimes(6) - expect(fixture.confirmSelection).toHaveBeenCalledTimes(1) - }) - - it('rejects a completed animation task that does not contain exactly 32 frames', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '一位像素风守夜人', - }) - const characters = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characters.candidates[0]!) - const actionRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '挥手', - actionType: 'custom', - fps: 12, - }) - const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch - const underfilled: Generation<'complete_animation'> = { - id: 'generation-underfilled', - projectId: 'project-1', - type: 'complete_animation', - status: 'completed', - result: { - type: 'complete_animation', - frames: Array.from({ length: 7 }, (_, index) => ({ - url: `underfilled-${index}.png`, - })), - }, - error: null, - } - fixture.generation.apis.create = vi.fn(async () => { - fixture.generation.tasks.set(underfilled.id, underfilled) - return underfilled - }) as GenerationApis['create'] - - await expect(actionRun.confirmActionFirstFrame(firstFrames.candidates[0]!)).rejects.toThrow( - '动作生成应返回 32 帧,实际返回 7 帧', - ) - expect(actionRun.snapshot()).toMatchObject({ status: 'failed' }) - expect(currentSteps(actionRun.snapshot())[1]).toMatchObject({ - status: 'failed', - error: '动作生成应返回 32 帧,实际返回 7 帧', - }) - }) - - it('marks the Action Step failed when no confirmed master can be loaded', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '一位像素风守夜人', - }) - const characters = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characters.candidates[0]!) - const actionRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '挥手', - actionType: 'custom', - fps: 12, - }) - const withoutMaster = createCharacter() - withoutMaster.referenceImageUrl = null - withoutMaster.outfits[0]!.previewUrl = null - vi.mocked(fixture.characterApis.get).mockResolvedValue(withoutMaster) - - await expect(actionRun.start()).rejects.toThrow('动作生成需要已确认的角色母版') - - expect(actionRun.snapshot().status).toBe('failed') - expect(currentSteps(actionRun.snapshot())[1]).toMatchObject({ - status: 'failed', - error: '动作生成需要已确认的角色母版', - }) - await expect(fixture.repository.get(actionRun.id)).resolves.toMatchObject({ status: 'failed' }) - }) - - it('binds operations to the run instance and exposes character-scoped action append', async () => { - const { service } = createFixture() - expect(Object.keys(service).sort()).toEqual(['appendAction', 'create', 'get', 'getByCharacter']) - const created = await service.create({ - projectId: 'project-1', - characterPrompt: '角色', - }) - const restored = await service.get(created.id) - expect(restored?.id).toBe(created.id) - expect(restored?.snapshot()).toEqual(created.snapshot()) - await expect( - service.getByCharacter({ - projectId: 'project-1', - characterId: 'missing-character', - }), - ).resolves.toBeNull() - }) - - it('appends every new action for the same character image to one run', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '角色', - }) - const characterCandidates = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characterCandidates.candidates[0]!) - const idleRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '待机', - actionType: 'idle', - fps: 8, - }) - const idleFrames = (await idleRun.start()) as ActionFirstFrameCandidateBatch - await idleRun.confirmActionFirstFrame(idleFrames.candidates[0]!) - await idleRun.approveAction() - - const appended = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - - expect(appended.id).toBe(run.id) - expect(currentSteps(appended.snapshot())).toHaveLength(3) - expect(currentSteps(appended.snapshot())[2]).toMatchObject({ - type: 'action', - status: 'active', - phase: 'generating_action_candidates', - }) - expect(await fixture.repository.list('project-1')).toHaveLength(1) - - const walkFrames = (await appended.start()) as ActionFirstFrameCandidateBatch - await appended.confirmActionFirstFrame(walkFrames.candidates[0]!) - const published = await appended.approveAction() - expect(published.snapshot.id).toBe(run.id) - expect(published.snapshot.status).toBe('completed') - expect(published.character.outfits[0]?.actions.map((action) => action.name)).toEqual([ - '待机', - '行走', - ]) - }) - - it('allows the next action only after the current action has failed', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ projectId: 'project-1', characterPrompt: '角色' }) - const characters = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characters.candidates[0]!) - const failedRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '失败动作', - actionType: 'custom', - fps: 12, - }) - - await expect( - fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '过早追加', - actionType: 'custom', - fps: 12, - }), - ).rejects.toThrow('当前动作结束后才能追加新动作') - - fixture.generation.apis.create = vi.fn(async () => { - throw new Error('生成失败') - }) as GenerationApis['create'] - await expect(failedRun.start()).rejects.toThrow('生成失败') - - const nextRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '下一个动作', - actionType: 'idle', - fps: 8, - }) - expect(currentSteps(nextRun.snapshot()).map((step) => step.status)).toEqual([ - 'passed', - 'failed', - 'active', - ]) - }) - - it('turns an uploaded template into a formal character binding without a fake generation', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '上传的像素守夜人', - }) - - const accepted = await run.acceptUploadedCharacterTemplate({ - referenceMedia: 'media://uploaded-template' as MediaReference, - description: '用户上传的像素守夜人', - }) - const revision = currentRevisionSnapshot(accepted) - - expect(revision).toMatchObject({ - characterOrigin: 'uploaded', - characterId: 'character-1', - outfitId: 'outfit-1', - }) - expect(revision.steps[0]).toMatchObject({ - type: 'character', - status: 'passed', - phase: 'completed', - generations: [], - }) - expect(revision.steps).toHaveLength(1) - expect(accepted.status).toBe('completed') - expect(fixture.characterApis.create).toHaveBeenCalledWith( - expect.objectContaining({ referenceImageUrl: 'media://uploaded-template' }), - ) - expect(fixture.generation.create).not.toHaveBeenCalled() - await expect(fixture.repository.get(run.id)).resolves.toMatchObject({ version: 2 }) - }) - - it('keeps ordinary state transitions local until save is explicitly requested', async () => { - const { service, repository } = createFixture() - const run = await service.create({ - projectId: 'project-1', - characterPrompt: '角色', - }) - - run.interrupt() - expect(run.snapshot().status).toBe('interrupted') - expect((await repository.get(run.id))?.status).toBe('active') - await run.save() - expect((await repository.get(run.id))?.status).toBe('interrupted') - run.continue() - expect(run.snapshot().status).toBe('active') - }) - - it('adds a read-only revision when Workflow Editor restarts from a card', async () => { - const { service } = createFixture() - const run = await service.create({ - projectId: 'project-1', - characterPrompt: '角色', - }) - const characterCandidates = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characterCandidates.candidates[0]!) - const actionRun = await service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - await actionRun.start() - - const before = actionRun.snapshot() - const parent = before.revisions[0]! - const actionStep = currentSteps(before)[1]! - const restarted = actionRun.restartFromStep(actionStep.id) - const next = restarted.revisions[1]! - - expect(restarted.id).toBe(before.id) - expect(restarted.revisions).toHaveLength(2) - expect(next).toMatchObject({ - parentRevisionId: parent.id, - restartedFromStepId: actionStep.id, - }) - expect(next.steps[0]).toMatchObject({ type: 'character', status: 'passed', phase: 'completed' }) - expect(next.steps[0]?.generations).toEqual(parent.steps[0]?.generations) - expect(next.steps[0]?.id).not.toBe(parent.steps[0]?.id) - expect(next.characterId).toBe(parent.characterId) - expect(next.outfitId).toBe(parent.outfitId) - expect(next.steps[1]).toMatchObject({ - type: 'action', - status: 'active', - phase: 'configuring_action', - generations: [], - }) - expect(next.actionInputs[next.steps[1]!.id]).toEqual(parent.actionInputs[actionStep.id]) - expect(restarted.revisions[0]).toEqual(parent) - }) - - it('requires a new Character and WorkflowRun when regenerating the character image', async () => { - const { service } = createFixture() - const run = await service.create({ projectId: 'project-1', characterPrompt: '角色' }) - - expect(() => run.restartFromStep(currentSteps(run.snapshot())[0]!.id)).toThrow( - '重新生成角色必须创建新的 Character 和 WorkflowRun', - ) - expect(run.snapshot().revisions).toHaveLength(1) - }) - - it('does not let an old revision asynchronous result mutate the new revision', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '异步角色', - }) - const characters = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characters.candidates[0]!) - const actionRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch - - const running: Generation<'complete_animation'> = { - id: 'generation-running', - projectId: 'project-1', - type: 'complete_animation', - status: 'running', - result: null, - error: null, - } - let emit: (event: GenerationEvent) => void = () => { - throw new Error('生成订阅尚未建立') - } - fixture.generation.apis.create = vi.fn(async () => running) as GenerationApis['create'] - fixture.generation.apis.get = vi.fn(async (_projectId, taskId) => { - if (taskId === running.id) return running - const generation = fixture.generation.tasks.get(taskId) - if (!generation) throw new Error(`GenerationTask 不存在:${taskId}`) - return structuredClone(generation) - }) - fixture.generation.apis.subscribe = vi.fn((_projectId, _taskId, onEvent) => { - emit = onEvent - return () => undefined - }) - - const pending = actionRun.confirmActionFirstFrame(firstFrames.candidates[0]!) - await vi.waitFor(() => expect(fixture.generation.apis.subscribe).toHaveBeenCalledTimes(1)) - const oldStep = currentSteps(actionRun.snapshot())[1]! - actionRun.restartFromStep(oldStep.id) - emit({ - taskId: running.id, - type: 'complete_animation', - status: 'completed', - result: { - type: 'complete_animation', - frames: Array.from({ length: COMPLETE_ANIMATION_FRAME_COUNT }, (_, index) => ({ - url: `late-frame-${index}.png`, - })), - }, - error: null, - }) - - await expect(pending).rejects.toThrow('已切换到新的 Revision') - const snapshot = actionRun.snapshot() - expect(snapshot.revisions).toHaveLength(2) - expect(currentSteps(snapshot)[1]).toMatchObject({ - status: 'active', - phase: 'configuring_action', - generations: [], - }) - }) - - it('rejects character and action candidates that do not belong to this run', async () => { - const { service, generation, confirmSelection } = createFixture() - const run = await service.create({ - projectId: 'project-1', - characterPrompt: '角色', - }) - await run.start() - await expect(run.confirmCharacter('foreign.png')).rejects.toThrow('不属于当前角色生成任务') - expect(confirmSelection).not.toHaveBeenCalled() - - await run.confirmCharacter('candidate-1.png') - const actionRun = await service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - await actionRun.start() - await expect(actionRun.confirmActionFirstFrame('foreign.png')).rejects.toThrow( - '不属于当前动作首帧任务', - ) - expect(generation.create).toHaveBeenCalledTimes(5) - }) - - it('resumes an action from persisted GenerationTask references', async () => { - const fixture = createFixture() - const run = await fixture.service.create({ - projectId: 'project-1', - characterPrompt: '角色', - }) - const characters = (await run.start()) as CharacterCandidateBatch - await run.confirmCharacter(characters.candidates[0]!) - const actionRun = await fixture.service.appendAction({ - projectId: 'project-1', - characterId: 'character-1', - actionName: '行走', - actionType: 'walk', - fps: 12, - }) - const firstFrames = (await actionRun.start()) as ActionFirstFrameCandidateBatch - await actionRun.confirmActionFirstFrame(firstFrames.candidates[0]!) - - const restored = (await fixture.service.get(actionRun.id))! - const resumed = await restored.resumeAction() - expect(currentSteps(resumed)[1]).toMatchObject({ phase: 'reviewing_animation' }) - expect(fixture.generation.create).toHaveBeenCalledTimes(6) - }) -}) diff --git a/frontend/src/entities/workflow-run/service/workflow-run-service.ts b/frontend/src/entities/workflow-run/service/workflow-run-service.ts deleted file mode 100644 index 4bc0c1e1..00000000 --- a/frontend/src/entities/workflow-run/service/workflow-run-service.ts +++ /dev/null @@ -1,950 +0,0 @@ -/** WorkflowRun 的运行实例与用例入口。 */ - -import type { Action, Character, CharacterApis, Frame } from '../../character' -import { - COMPLETE_ANIMATION_FRAME_COUNT, - type CharacterTemplateGenerationResult, - type CompleteAnimationGenerationResult, - type Generation, - type GenerationApis, - type GenerationEvent, -} from '../../generation' -import type { MediaReference } from '../../media' -import { ACTION_FIRST_FRAME_CANDIDATE_COUNT, CHARACTER_CANDIDATE_COUNT } from '../model' -import type { - AcceptUploadedCharacterTemplateInput, - AppendWorkflowActionInput, - ConfigureWorkflowActionInput, - CreateWorkflowRunInput, - WorkflowGenerationRole, - WorkflowRevision, - WorkflowRunCharacterBinding, - WorkflowRunSnapshot, - WorkflowStep, - WorkflowStepType, -} from '../model' -import type { WorkflowRunRepository } from '../store' - -/** - * 确认角色候选的后端原子操作。 - * 后端保存选中图并清理其余三个临时候选,前端只接收正式角色和造型 ID。 - */ -export interface CharacterCandidateConfirmationApis { - confirmSelection(input: { - projectId: string - generationId: string - selectedImageUrl: string - description: string - }): Promise<{ character: Character; outfitId: string }> -} - -export interface CharacterCandidateBatch { - snapshot: WorkflowRunSnapshot - generationId: string - /** 候选 URL 只用于当前选择界面,不写入 WorkflowRun。 */ - candidates: readonly string[] -} - -export interface ActionFirstFrameCandidateBatch { - snapshot: WorkflowRunSnapshot - candidateTaskIds: readonly string[] - /** 候选 URL 只用于当前选择界面,不写入 WorkflowRun。 */ - candidates: readonly string[] -} - -export interface ActionReviewResult { - snapshot: WorkflowRunSnapshot - generationId: string - frames: readonly { imageUrl: string }[] -} - -export interface PublishActionResult { - snapshot: WorkflowRunSnapshot - character: Character - characterId: string - actionId: string -} - -/** - * 一个可执行的 WorkflowRun 领域对象。 - * - * 它是角色制作任务的聚合根,负责维护运行状态并执行状态转换。页面持有这个对象即可, - * 不再同时传递 Service 和 runId;Repository 只保存它通过 `snapshot()` 暴露的纯数据快照。 - * `snapshot()` 返回可渲染数据;`save()` 是显式持久化边界。 - */ -export interface WorkflowRun { - readonly id: string - snapshot(): WorkflowRunSnapshot - save(): Promise - interrupt(): WorkflowRunSnapshot - continue(): WorkflowRunSnapshot - /** Workflow Editor 从指定卡片重做,并把旧 Revision 保留为只读历史。 */ - restartFromStep(stepId: WorkflowStep['id']): WorkflowRunSnapshot - start(): Promise - resumeCharacterCandidates(): Promise - confirmCharacter(selectedImageUrl: string): Promise - /** 上传成功后立即创建正式 Character/Outfit,并把 ID 写入当前 Run。 */ - acceptUploadedCharacterTemplate( - input: AcceptUploadedCharacterTemplateInput, - ): Promise - configureAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot - /** 给当前角色图追加动作卡片;不会创建新的 WorkflowRun。 */ - appendAction(input: ConfigureWorkflowActionInput): WorkflowRunSnapshot - resumeActionFirstFrameCandidates(): Promise - confirmActionFirstFrame(selectedImageUrl: string): Promise - resumeAction(): Promise - getActionReview(): Promise - approveAction(): Promise -} - -/** Service 只负责创建或恢复运行实例。 */ -export interface WorkflowRunService { - create(input: CreateWorkflowRunInput): Promise - get(runId: WorkflowRunSnapshot['id']): Promise - getByCharacter(binding: WorkflowRunCharacterBinding): Promise - /** 根据 Character 找到原 Run 并追加动作;找不到时失败,绝不偷偷创建第二个 Run。 */ - appendAction(input: AppendWorkflowActionInput): Promise -} - -export interface CreateWorkflowRunServiceOptions { - repository: WorkflowRunRepository - /** - * 前端只提交用户意图并订阅 GenerationTask;实际模型输入和执行方式由后端决定。 - * WorkflowRun Service 不读取后端内部执行过程,也不承担模型供应商调用。 - */ - generationApis: GenerationApis - characterApis: CharacterApis - candidateConfirmationApis: CharacterCandidateConfirmationApis - createId?: () => string - now?: () => string -} - -export function createWorkflowRunService( - options: CreateWorkflowRunServiceOptions, -): WorkflowRunService { - const createId = options.createId ?? createRandomId - const now = options.now ?? (() => new Date().toISOString()) - - function bind(initial: WorkflowRunSnapshot): WorkflowRun { - let state = structuredClone(initial) - - const current = (): WorkflowRunSnapshot => structuredClone(state) - const replace = (next: WorkflowRunSnapshot): WorkflowRunSnapshot => { - state = structuredClone(next) - return current() - } - const persist = async (): Promise => { - state = await options.repository.save(state, state.version) - return current() - } - const mutate = (edit: (draft: WorkflowRunSnapshot) => void): WorkflowRunSnapshot => { - const draft = current() - edit(draft) - draft.updatedAt = now() - return replace(draft) - } - const checkpoint = async (edit: (draft: WorkflowRunSnapshot) => void) => { - mutate(edit) - return persist() - } - - async function fail(message: string, revisionId: string): Promise { - if (state.status !== 'active' || state.currentRevisionId !== revisionId) return - mutate((draft) => { - assertCurrentRevision(draft, revisionId) - const step = requireActiveStep(draft) - step.status = 'failed' - step.error = message - draft.status = 'failed' - }) - await persist() - } - - async function generateCharacterCandidates(): Promise { - assertActive(state) - const revisionId = state.currentRevisionId - const step = requireStep(state, 'character') - if (step.phase === 'selecting_character') return loadCharacterCandidates() - if (step.phase !== 'generating_character_candidates') { - throw new Error('角色卡片当前不能生成候选图') - } - const input = currentRevision(state).characterInput - if (!input) throw new Error('WorkflowRun 缺少角色生成输入') - - try { - let generationId = generationIdFor(step, 'character_candidates') - if (!generationId) { - const generation = await options.generationApis.create({ - type: 'character_template', - projectId: state.projectId, - prompt: input.prompt, - referenceMedia: input.referenceMedia, - spriteWidth: input.spriteWidth, - spriteHeight: input.spriteHeight, - }) - generationId = generation.id - assertCurrentRevision(state, revisionId) - await checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - requireStep(draft, 'character').generations.push({ - taskId: generation.id, - role: 'character_candidates', - }) - }) - } - const terminal = await waitForTerminal( - options.generationApis, - await options.generationApis.get(state.projectId, generationId), - ) - const result = requireCharacterCandidates(terminal) - assertActive(state) - assertCurrentRevision(state, revisionId) - await checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - requireStep(draft, 'character').phase = 'selecting_character' - }) - return toCharacterBatch(state, generationId, result) - } catch (cause) { - await fail(errorMessage(cause, '角色候选生成失败'), revisionId) - throw asError(cause) - } - } - - async function loadCharacterCandidates(): Promise { - const step = requireStep(state, 'character') - const generationId = generationIdFor(step, 'character_candidates') - if (!generationId) throw new Error('角色候选任务 ID 不存在') - const result = requireCharacterCandidates( - await options.generationApis.get(state.projectId, generationId), - ) - return toCharacterBatch(state, generationId, result) - } - - async function collectActionCandidates(): Promise { - assertActive(state) - const revisionId = state.currentRevisionId - const step = requireActiveActionStep(state) - if (step.phase === 'selecting_action_frame') return loadActionCandidates() - if (step.phase !== 'generating_action_candidates') { - throw new Error('动作卡片当前不能生成首帧候选') - } - const action = requireActionInput(state) - const { characterId, outfitId } = requireCharacterBinding(state) - - try { - // 母版读取也属于本次生成动作。读取失败时必须把 Action Step 标记为 failed, - // 否则刷新页面会把它误当成仍在生成并一直尝试恢复一个不存在的任务。 - const masterReference = await loadMasterReference(characterId, outfitId, revisionId) - while ( - generationIdsFor(requireActiveActionStep(state), 'action_frame_candidate').length < - ACTION_FIRST_FRAME_CANDIDATE_COUNT - ) { - const generation = await options.generationApis.create({ - type: 'first_frame', - projectId: state.projectId, - characterId, - outfitId, - actionType: action.type, - prompt: action.prompt, - referenceMedia: [masterReference], - }) - assertCurrentRevision(state, revisionId) - await checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - requireActiveActionStep(draft).generations.push({ - taskId: generation.id, - role: 'action_frame_candidate', - }) - }) - } - - const batch = await loadActionCandidates() - assertActive(state) - assertCurrentRevision(state, revisionId) - await checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - requireActiveActionStep(draft).phase = 'selecting_action_frame' - }) - return { ...batch, snapshot: current() } - } catch (cause) { - await fail(errorMessage(cause, '动作首帧候选生成失败'), revisionId) - throw asError(cause) - } - } - - async function loadActionCandidates(): Promise { - const step = requireActiveActionStep(state) - const taskIds = generationIdsFor(step, 'action_frame_candidate') - if (taskIds.length !== ACTION_FIRST_FRAME_CANDIDATE_COUNT) { - throw new Error(`动作首帧必须包含 ${ACTION_FIRST_FRAME_CANDIDATE_COUNT} 个候选任务`) - } - const candidates = await Promise.all( - taskIds.map(async (taskId) => { - const terminal = await waitForTerminal( - options.generationApis, - await options.generationApis.get(state.projectId, taskId), - ) - return requireFirstFrame(terminal) - }), - ) - return { snapshot: current(), candidateTaskIds: taskIds, candidates } - } - - const run: WorkflowRun = { - id: state.id, - snapshot: current, - save: persist, - interrupt() { - if (state.status !== 'active') throw new Error('只有进行中的 WorkflowRun 可以中断') - return mutate((draft) => { - draft.status = 'interrupted' - }) - }, - continue() { - if (state.status !== 'interrupted') throw new Error('只有已中断的 WorkflowRun 可以继续') - return mutate((draft) => { - draft.status = 'active' - }) - }, - restartFromStep(stepId) { - const parent = currentRevision(state) - const target = parent.steps.find((step) => step.id === stepId) - if (!target) throw new Error('重做目标不属于当前 Revision') - if (target.type === 'character') { - throw new Error('重新生成角色必须创建新的 Character 和 WorkflowRun') - } - if ( - parent.steps.some( - (step) => step !== target && !['passed', 'failed'].includes(step.status), - ) - ) { - throw new Error('当前还有其他未完成卡片,不能重做这个动作') - } - - const createdAt = now() - const actionInputs: WorkflowRevision['actionInputs'] = {} - const steps = parent.steps.map((source) => { - const next = createRestartedStep(source, source === target, createId) - if (source.type === 'action') { - const input = parent.actionInputs[source.id] - if (input) actionInputs[next.id] = structuredClone(input) - } - return next - }) - const revision: WorkflowRevision = { - id: createId(), - parentRevisionId: parent.id, - restartedFromStepId: stepId, - steps, - characterInput: structuredClone(parent.characterInput), - characterOrigin: parent.characterOrigin, - characterId: parent.characterId, - outfitId: parent.outfitId, - characterSelectedAt: parent.characterSelectedAt, - actionInputs, - createdAt, - } - return mutate((draft) => { - draft.revisions.push(revision) - draft.currentRevisionId = revision.id - draft.status = 'active' - }) - }, - async start() { - const step = requireActiveStep(state) - return step.type === 'character' ? generateCharacterCandidates() : collectActionCandidates() - }, - resumeCharacterCandidates: generateCharacterCandidates, - async confirmCharacter(selectedImageUrl) { - assertActive(state) - const revisionId = state.currentRevisionId - const step = requireStep(state, 'character') - if (step.phase !== 'selecting_character') throw new Error('角色尚未进入候选选择阶段') - const batch = await loadCharacterCandidates() - if (!batch.candidates.includes(selectedImageUrl)) { - throw new Error('选中图片不属于当前角色生成任务') - } - assertCurrentRevision(state, revisionId) - const characterInput = currentRevision(state).characterInput - if (!characterInput) throw new Error('WorkflowRun 缺少角色生成输入') - const confirmed = await options.candidateConfirmationApis.confirmSelection({ - projectId: state.projectId, - generationId: batch.generationId, - selectedImageUrl, - description: characterInput.prompt, - }) - assertCurrentRevision(state, revisionId) - return checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - const characterStep = requireStep(draft, 'character') - characterStep.status = 'passed' - characterStep.phase = 'completed' - const revision = currentRevision(draft) - revision.characterOrigin = 'generated' - revision.characterId = confirmed.character.id - revision.outfitId = confirmed.outfitId - revision.characterSelectedAt = now() - draft.status = 'completed' - }) - }, - async acceptUploadedCharacterTemplate(input) { - assertActive(state) - const revisionId = state.currentRevisionId - const revision = currentRevision(state) - const characterStep = requireStep(state, 'character') - if ( - characterStep.phase !== 'generating_character_candidates' || - characterStep.generations.length > 0 || - revision.characterOrigin !== null - ) { - throw new Error('当前角色卡片不能采用上传母版') - } - const referenceImageUrl = String(input.referenceMedia).trim() - if (!referenceImageUrl) throw new TypeError('上传母版引用不能为空') - const description = input.description?.trim() || revision.characterInput?.prompt || '' - - let character = await options.characterApis.create({ - projectId: state.projectId, - name: input.name?.trim() || null, - description, - referenceImageUrl, - }) - assertCurrentRevision(state, revisionId) - if (character.outfits.length === 0) { - character = await options.characterApis.update({ - ...character, - outfits: [ - { - id: `outfit-${character.id}-default`, - characterId: character.id, - name: '默认造型', - description: null, - previewUrl: referenceImageUrl, - actions: [], - }, - ], - }) - assertCurrentRevision(state, revisionId) - } - const outfitId = character.outfits[0]?.id - if (!outfitId) throw new Error('角色服务没有返回可用造型') - - return checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - const draftRevision = currentRevision(draft) - if (!draftRevision.characterInput) throw new Error('WorkflowRun 缺少角色输入') - draftRevision.characterInput.referenceMedia = [input.referenceMedia] - if (description) draftRevision.characterInput.prompt = description - draftRevision.characterOrigin = 'uploaded' - draftRevision.characterId = character.id - draftRevision.outfitId = outfitId - draftRevision.characterSelectedAt = now() - const draftCharacterStep = requireStep(draft, 'character') - draftCharacterStep.status = 'passed' - draftCharacterStep.phase = 'completed' - draft.status = 'completed' - }) - }, - configureAction(input) { - assertActive(state) - const step = requireActiveActionStep(state) - if (step.phase !== 'configuring_action') throw new Error('动作卡片当前不能配置') - validateActionInput(input) - return mutate((draft) => { - const actionStep = requireActiveActionStep(draft) - const previous = currentRevision(draft).actionInputs[actionStep.id] - currentRevision(draft).actionInputs[actionStep.id] = { - actionId: previous?.actionId ?? null, - name: input.actionName.trim(), - type: input.actionType, - prompt: input.actionPrompt?.trim() || null, - fps: input.fps, - } - actionStep.phase = 'generating_action_candidates' - }) - }, - appendAction(input) { - if (state.status !== 'completed' && state.status !== 'failed') { - throw new Error('当前动作结束后才能追加新动作') - } - requireCharacterBinding(state) - validateActionInput(input) - return mutate((draft) => { - const revision = currentRevision(draft) - const actionStep = createActionStep(createId) - revision.steps.push(actionStep) - revision.actionInputs[actionStep.id] = createActionInput(input) - draft.status = 'active' - }) - }, - resumeActionFirstFrameCandidates: collectActionCandidates, - async confirmActionFirstFrame(selectedImageUrl) { - assertActive(state) - const revisionId = state.currentRevisionId - const step = requireActiveActionStep(state) - if (step.phase !== 'selecting_action_frame') { - throw new Error('动作尚未进入首帧选择阶段') - } - const batch = await loadActionCandidates() - if (!batch.candidates.includes(selectedImageUrl)) { - throw new Error('选中图片不属于当前动作首帧任务') - } - assertCurrentRevision(state, revisionId) - const action = requireActionInput(state) - const { characterId, outfitId } = requireCharacterBinding(state) - try { - const masterReference = await loadMasterReference(characterId, outfitId, revisionId) - const generation = await options.generationApis.create({ - type: 'complete_animation', - projectId: state.projectId, - characterId, - outfitId, - actionType: action.type, - firstFrameUrl: selectedImageUrl, - prompt: action.prompt, - referenceMedia: [masterReference], - }) - assertCurrentRevision(state, revisionId) - await checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - const actionStep = requireActiveActionStep(draft) - actionStep.generations.push({ taskId: generation.id, role: 'animation' }) - actionStep.phase = 'generating_animation' - }) - return run.resumeAction() - } catch (cause) { - await fail(errorMessage(cause, '完整动画生成失败'), revisionId) - throw asError(cause) - } - }, - async resumeAction() { - assertActive(state) - const revisionId = state.currentRevisionId - const step = requireActiveActionStep(state) - if (step.phase === 'reviewing_animation') return current() - if (step.phase !== 'generating_animation') throw new Error('动作当前不在动画生成阶段') - const taskId = generationIdFor(step, 'animation') - if (!taskId) throw new Error('完整动画任务 ID 不存在') - try { - requireAnimation( - await waitForTerminal( - options.generationApis, - await options.generationApis.get(state.projectId, taskId), - ), - ) - assertActive(state) - assertCurrentRevision(state, revisionId) - return checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - requireActiveActionStep(draft).phase = 'reviewing_animation' - }) - } catch (cause) { - await fail(errorMessage(cause, '完整动画恢复失败'), revisionId) - throw asError(cause) - } - }, - async getActionReview() { - const step = requireActiveActionStep(state) - if (state.status !== 'active' || step.phase !== 'reviewing_animation') { - throw new Error('动作尚未进入可审核状态') - } - const generationId = generationIdFor(step, 'animation') - if (!generationId) throw new Error('完整动画任务 ID 不存在') - const animation = requireAnimation( - await options.generationApis.get(state.projectId, generationId), - ) - return { - snapshot: current(), - generationId, - frames: animation.frames.map((frame) => ({ imageUrl: frame.url })), - } - }, - async approveAction() { - const revisionId = state.currentRevisionId - const review = await run.getActionReview() - assertCurrentRevision(state, revisionId) - const actionInput = requireActionInput(state) - const { characterId, outfitId } = requireCharacterBinding(state) - const character = await options.characterApis.get(characterId) - const outfit = character.outfits.find((item) => item.id === outfitId) - if (!outfit) throw new Error('动作所属默认造型不存在') - const previousActionIds = new Set(outfit.actions.map((item) => item.id)) - const requestedActionId = actionInput.actionId ?? createId() - const action: Action = { - id: requestedActionId, - outfitId, - name: actionInput.name, - type: actionInput.type, - loop: ['idle', 'walk', 'run'].includes(actionInput.type), - fps: actionInput.fps, - frameCount: COMPLETE_ANIMATION_FRAME_COUNT, - frames: review.frames.map((frame, index) => ({ - index, - imageUrl: frame.imageUrl, - durationMs: null, - })), - } - const savedCharacter = await options.characterApis.update({ - ...character, - outfits: character.outfits.map((item) => - item.id === outfitId - ? { - ...item, - actions: [ - ...item.actions.filter((existing) => existing.id !== requestedActionId), - action, - ], - } - : item, - ), - }) - assertCurrentRevision(state, revisionId) - const savedOutfit = savedCharacter.outfits.find((item) => item.id === outfitId) - const persistedAction = - savedOutfit?.actions.find((item) => item.id === actionInput.actionId) ?? - savedOutfit?.actions.find((item) => !previousActionIds.has(item.id)) - if (!persistedAction) throw new Error('角色服务没有返回已保存动作的正式 ID') - await checkpoint((draft) => { - assertCurrentRevision(draft, revisionId) - const actionStep = requireActiveActionStep(draft) - const draftActionInput = currentRevision(draft).actionInputs[actionStep.id] - if (!draftActionInput) throw new Error('WorkflowRun 缺少动作输入') - draftActionInput.actionId = persistedAction.id - actionStep.phase = 'completed' - actionStep.status = 'passed' - draft.status = 'completed' - }) - return { - snapshot: current(), - character: savedCharacter, - characterId, - actionId: persistedAction.id, - } - }, - } - - /** - * 动作生成必须把用户确认后的角色母版显式传给 Generation API。Character 顶层参考图是 - * 兼容回退;正式造型预览优先。这里返回 MediaReference 只是跨前端接口传递同一 URL, - * 不重新上传文件,也不把母版内容写进 WorkflowRun。 - */ - async function loadMasterReference( - characterId: string, - outfitId: string, - revisionId: string, - ): Promise { - const character = await options.characterApis.get(characterId) - assertCurrentRevision(state, revisionId) - const outfit = character.outfits.find((item) => item.id === outfitId) - const reference = outfit?.previewUrl ?? character.referenceImageUrl - if (!outfit || !reference?.trim()) throw new Error('动作生成需要已确认的角色母版') - return reference as MediaReference - } - return run - } - - return { - async create(input) { - const state = createInitialSnapshot(input, createId, now) - return bind(await options.repository.create(state)) - }, - async get(runId) { - const state = await options.repository.get(runId) - return state ? bind(state) : null - }, - async getByCharacter(binding) { - const state = await options.repository.getByCharacter(binding) - return state ? bind(state) : null - }, - async appendAction(input) { - validateCharacterBinding(input) - validateActionInput(input) - const state = await options.repository.getByCharacter(input) - if (!state) throw new Error('该 Character 没有可追加动作的 WorkflowRun') - const run = bind(state) - run.appendAction(input) - await run.save() - return run - }, - } -} - -function createInitialSnapshot( - input: CreateWorkflowRunInput, - createId: () => string, - now: () => string, -): WorkflowRunSnapshot { - if (!input.projectId.trim()) throw new TypeError('projectId 不能为空') - if (!input.characterPrompt.trim()) throw new TypeError('角色描述不能为空') - - const createdAt = now() - const characterStep = (): WorkflowStep => ({ - id: createId(), - nodeId: 'builtin-character', - type: 'character', - status: 'active', - phase: 'generating_character_candidates', - generations: [], - error: null, - }) - const steps = [characterStep()] - const initialRevisionId = createId() - - return { - id: createId(), - projectId: input.projectId.trim(), - version: 1, - source: { type: 'builtin', key: 'character_action', rootNodeId: steps[0]!.nodeId }, - status: 'active', - currentRevisionId: initialRevisionId, - revisions: [ - { - id: initialRevisionId, - parentRevisionId: null, - restartedFromStepId: null, - steps, - characterInput: { - prompt: input.characterPrompt.trim(), - referenceMedia: input.referenceMedia ?? [], - spriteWidth: input.spriteSize?.width ?? 256, - spriteHeight: input.spriteSize?.height ?? 256, - }, - characterOrigin: null, - characterId: null, - outfitId: null, - characterSelectedAt: null, - actionInputs: {}, - createdAt, - }, - ], - createdAt, - updatedAt: createdAt, - } -} - -function validateActionInput(input: ConfigureWorkflowActionInput): void { - if (!input.actionName.trim()) throw new TypeError('动作名称不能为空') - if (!Number.isFinite(input.fps) || input.fps <= 0) throw new TypeError('FPS 必须大于 0') -} - -function validateCharacterBinding(input: AppendWorkflowActionInput): void { - if (!input.projectId.trim() || !input.characterId.trim()) { - throw new TypeError('追加动作必须绑定项目和角色') - } -} - -function requireStep(run: WorkflowRunSnapshot, type: WorkflowStepType): WorkflowStep { - const step = currentRevision(run).steps.find((item) => item.type === type) - if (!step) throw new Error(`WorkflowRun 缺少 ${type} 卡片`) - return step -} - -function requireActiveStep(run: WorkflowRunSnapshot): WorkflowStep { - const step = currentRevision(run).steps.find((item) => item.status === 'active') - if (!step) throw new Error('WorkflowRun 没有当前活动卡片') - return step -} - -function requireActiveActionStep(run: WorkflowRunSnapshot): WorkflowStep { - const step = requireActiveStep(run) - if (step.type !== 'action') throw new Error('WorkflowRun 当前活动卡片不是动作') - return step -} - -function currentRevision(run: WorkflowRunSnapshot): WorkflowRevision { - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - if (!revision) throw new Error('WorkflowRun 的当前 Revision 不存在') - return revision -} - -function assertCurrentRevision(run: WorkflowRunSnapshot, revisionId: string): void { - if (run.currentRevisionId !== revisionId) { - throw new Error('WorkflowRun 已切换到新的 Revision,忽略旧分支的异步结果') - } -} - -function createRestartedStep( - source: WorkflowStep, - restart: boolean, - createId: () => string, -): WorkflowStep { - const step = structuredClone(source) - step.id = createId() - if (!restart) return step - - step.generations = [] - step.error = null - step.status = 'active' - step.phase = - source.type === 'character' ? 'generating_character_candidates' : 'configuring_action' - return step -} - -function createActionStep(createId: () => string): WorkflowStep { - const id = createId() - return { - id, - nodeId: `builtin-action:${id}`, - type: 'action', - status: 'active', - phase: 'generating_action_candidates', - generations: [], - error: null, - } -} - -function createActionInput(input: ConfigureWorkflowActionInput) { - return { - actionId: null, - name: input.actionName.trim(), - type: input.actionType, - prompt: input.actionPrompt?.trim() || null, - fps: input.fps, - } -} - -function assertActive(run: WorkflowRunSnapshot): void { - if (run.status !== 'active') throw new Error('WorkflowRun 当前不能继续推进') -} - -function requireCharacterBinding(run: WorkflowRunSnapshot): { - characterId: string - outfitId: string -} { - const revision = currentRevision(run) - if (!revision.characterId || !revision.outfitId) { - throw new Error('WorkflowRun 尚未绑定角色和造型') - } - return { characterId: revision.characterId, outfitId: revision.outfitId } -} - -function requireActionInput(run: WorkflowRunSnapshot) { - const revision = currentRevision(run) - const actionInput = revision.actionInputs[requireActiveActionStep(run).id] - if (!actionInput) throw new Error('WorkflowRun 尚未配置动作') - return actionInput -} - -function generationIdsFor(step: WorkflowStep, role: WorkflowGenerationRole): string[] { - return step.generations.filter((item) => item.role === role).map((item) => item.taskId) -} - -function generationIdFor(step: WorkflowStep, role: WorkflowGenerationRole): string | null { - return generationIdsFor(step, role)[0] ?? null -} - -function requireCharacterCandidates(generation: Generation): CharacterTemplateGenerationResult { - if (generation.status === 'failed') throw new Error(generation.error || '角色候选生成失败') - if ( - generation.type !== 'character_template' || - generation.status !== 'completed' || - generation.result?.type !== 'character_template' || - generation.result.images.length !== CHARACTER_CANDIDATE_COUNT || - generation.result.images.some((image) => !image.url) - ) { - throw new Error(`角色生成必须返回 ${CHARACTER_CANDIDATE_COUNT} 张有效候选图`) - } - return generation.result -} - -function requireAnimation(generation: Generation): CompleteAnimationGenerationResult { - if (generation.status === 'failed') throw new Error(generation.error || '完整动画生成失败') - if ( - generation.type !== 'complete_animation' || - generation.status !== 'completed' || - generation.result?.type !== 'complete_animation' - ) { - throw new Error('完整动画任务没有返回有效帧') - } - if (generation.result.frames.length !== COMPLETE_ANIMATION_FRAME_COUNT) { - throw new Error( - `动作生成应返回 ${COMPLETE_ANIMATION_FRAME_COUNT} 帧,实际返回 ${generation.result.frames.length} 帧`, - ) - } - if (generation.result.frames.some((frame) => !frame.url)) { - throw new Error('完整动画任务没有返回有效帧') - } - return generation.result -} - -function requireFirstFrame(generation: Generation): string { - if (generation.status === 'failed') throw new Error(generation.error || '首帧生成失败') - if ( - generation.type !== 'first_frame' || - generation.status !== 'completed' || - generation.result?.type !== 'first_frame' || - !generation.result.image.url - ) { - throw new Error('首帧生成未返回有效图片') - } - return generation.result.image.url -} - -function toCharacterBatch( - snapshot: WorkflowRunSnapshot, - generationId: string, - result: CharacterTemplateGenerationResult, -): CharacterCandidateBatch { - return { - snapshot: structuredClone(snapshot), - generationId, - candidates: result.images.map((image) => image.url), - } -} - -function waitForTerminal( - generationApis: GenerationApis, - generation: Generation, -): Promise { - if (generation.status === 'completed' || generation.status === 'failed') { - return Promise.resolve(generation) - } - return new Promise((resolve, reject) => { - let stop: () => void = () => undefined - let settled = false - const fail = (cause: unknown) => { - if (settled) return - settled = true - stop() - reject(asError(cause)) - } - const settleGeneration = (snapshot: Generation) => { - if (settled || (snapshot.status !== 'completed' && snapshot.status !== 'failed')) return - settled = true - stop() - resolve(snapshot) - } - const settleEvent = (event: GenerationEvent) => { - settleGeneration({ - id: event.taskId, - projectId: generation.projectId, - type: event.type, - status: event.status, - result: event.result, - error: event.error, - }) - } - try { - stop = generationApis.subscribe(generation.projectId, generation.id, settleEvent) - if (settled) return stop() - void generationApis.get(generation.projectId, generation.id).then(settleGeneration, fail) - } catch (cause) { - fail(cause) - } - }) -} - -function createRandomId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() - } - return `workflow-${Date.now()}-${Math.random().toString(16).slice(2)}` -} - -function errorMessage(cause: unknown, fallback: string): string { - return cause instanceof Error && cause.message.trim() ? cause.message.trim() : fallback -} - -function asError(cause: unknown): Error { - return cause instanceof Error ? cause : new Error(String(cause)) -} diff --git a/frontend/src/entities/workflow-run/store.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/entities/workflow-run/store/index.ts b/frontend/src/entities/workflow-run/store/index.ts deleted file mode 100644 index c65cee14..00000000 --- a/frontend/src/entities/workflow-run/store/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** WorkflowRun 的异步持久化边界。 */ - -export { - createWorkflowRunRepository, - isWorkflowRunSnapshot, - WORKFLOW_RUN_STORAGE_KEY, - WORKFLOW_RUN_STORAGE_VERSION, -} from './workflow-run-store' -export type { - CreateWorkflowRunRepositoryOptions, - WorkflowRunRepository, -} from './workflow-run-store' diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts deleted file mode 100644 index c84f51d0..00000000 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { WorkflowRunSnapshot } from '../model' -import { - createWorkflowRunRepository, - isWorkflowRunSnapshot, - WORKFLOW_RUN_STORAGE_VERSION, -} from './workflow-run-store' - -function createSnapshot(id = 'run-1'): WorkflowRunSnapshot { - return { - id, - projectId: 'project-1', - version: 1, - source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, - status: 'active', - currentRevisionId: 'revision-1', - revisions: [ - { - id: 'revision-1', - parentRevisionId: null, - restartedFromStepId: null, - createdAt: '2026-08-05T00:00:00.000Z', - steps: [ - { - id: 'step-character', - nodeId: 'character-node', - type: 'character', - status: 'active', - phase: 'generating_character_candidates', - generations: [], - error: null, - }, - ], - characterInput: { - prompt: '像素骑士', - referenceMedia: [], - spriteWidth: 256, - spriteHeight: 256, - }, - characterOrigin: null, - characterId: null, - outfitId: null, - characterSelectedAt: null, - actionInputs: {}, - }, - ], - createdAt: '2026-08-05T00:00:00.000Z', - updatedAt: '2026-08-05T00:00:00.000Z', - } -} - -function createMemoryStorage(initial: string | null = null) { - let value = initial - return { - getItem: () => value, - setItem: (_key: string, next: string) => { - value = next - }, - read: () => value, - } -} - -describe('WorkflowRunRepository', () => { - it('uses an asynchronous CRUD contract without change subscriptions', async () => { - const repository = createWorkflowRunRepository({ storage: null }) - expect('subscribe' in repository).toBe(false) - expect('subscribeAll' in repository).toBe(false) - - const createdPromise = repository.create(createSnapshot()) - expect(createdPromise).toBeInstanceOf(Promise) - await createdPromise - await expect(repository.get('run-1')).resolves.toMatchObject({ id: 'run-1' }) - await expect(repository.list('project-1')).resolves.toHaveLength(1) - await expect(repository.save(createSnapshot(), 1)).resolves.toMatchObject({ - id: 'run-1', - version: 2, - }) - }) - - it('persists a versioned snapshot and hydrates it in a new repository', async () => { - const storage = createMemoryStorage() - const repository = createWorkflowRunRepository({ storage }) - await repository.create(createSnapshot()) - - expect(JSON.parse(storage.read()!)).toMatchObject({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [{ id: 'run-1' }], - }) - const restored = createWorkflowRunRepository({ storage }) - await expect(restored.get('run-1')).resolves.toEqual(createSnapshot()) - }) - - it('finds the single run bound to a Character without exposing Outfit as identity', async () => { - const repository = createWorkflowRunRepository({ storage: null }) - const snapshot = createSnapshot() - const revision = snapshot.revisions[0]! - revision.steps[0]!.status = 'passed' - revision.steps[0]!.phase = 'completed' - revision.steps[0]!.generations = [ - { taskId: 'character-generation-1', role: 'character_candidates' }, - ] - snapshot.status = 'completed' - revision.characterId = 'character-1' - revision.outfitId = 'outfit-1' - revision.characterSelectedAt = '2026-08-05T00:01:00.000Z' - revision.characterOrigin = 'generated' - await repository.create(snapshot) - - await expect( - repository.getByCharacter({ - projectId: 'project-1', - characterId: 'character-1', - }), - ).resolves.toMatchObject({ id: snapshot.id }) - await expect( - repository.getByCharacter({ - projectId: 'project-1', - characterId: 'another-character', - }), - ).resolves.toBeNull() - }) - - it('rejects binding the same Character to a second WorkflowRun', async () => { - const repository = createWorkflowRunRepository({ storage: null }) - const first = createSnapshot('run-1') - const second = createSnapshot('run-2') - for (const snapshot of [first, second]) { - snapshot.status = 'completed' - const revision = snapshot.revisions[0]! - revision.steps[0]!.status = 'passed' - revision.steps[0]!.phase = 'completed' - revision.steps[0]!.generations = [ - { taskId: `generation-${snapshot.id}`, role: 'character_candidates' }, - ] - revision.characterOrigin = 'generated' - revision.characterId = 'character-1' - revision.outfitId = `internal-outfit-${snapshot.id}` - revision.characterSelectedAt = '2026-08-05T00:01:00.000Z' - } - - await repository.create(first) - await expect(repository.create(second)).rejects.toThrow( - '这个 Character 已经绑定到另一条 WorkflowRun', - ) - }) - - it('rejects a stale save instead of overwriting a newer snapshot', async () => { - const storage = createMemoryStorage() - const firstRepository = createWorkflowRunRepository({ storage }) - const secondRepository = createWorkflowRunRepository({ storage }) - await firstRepository.create(createSnapshot()) - - const firstCopy = (await firstRepository.get('run-1'))! - const staleCopy = (await secondRepository.get('run-1'))! - firstCopy.status = 'interrupted' - const saved = await firstRepository.save(firstCopy, firstCopy.version) - expect(saved.version).toBe(2) - - staleCopy.status = 'interrupted' - await expect(secondRepository.save(staleCopy, staleCopy.version)).rejects.toThrow( - '已被其他操作更新', - ) - await expect(secondRepository.get('run-1')).resolves.toMatchObject({ version: 2 }) - }) - - it('returns clones so callers cannot mutate persisted state without save', async () => { - const repository = createWorkflowRunRepository({ storage: null }) - await repository.create(createSnapshot()) - const loaded = (await repository.get('run-1'))! - loaded.status = 'interrupted' - - await expect(repository.get('run-1')).resolves.toMatchObject({ status: 'active' }) - }) - - it('rejects duplicate creation and structurally invalid card phases', async () => { - const repository = createWorkflowRunRepository({ storage: null }) - await repository.create(createSnapshot()) - await expect(repository.create(createSnapshot())).rejects.toThrow('已存在') - - const invalid = createSnapshot('run-invalid') - invalid.revisions[0]!.steps[0]!.phase = 'reviewing_animation' - expect(isWorkflowRunSnapshot(invalid)).toBe(false) - await expect(repository.save(invalid, invalid.version)).rejects.toThrow( - 'Invalid WorkflowRun snapshot', - ) - }) - - it('hydrates revision lineage only when parents and restart steps are valid', () => { - const snapshot = createSnapshot() - const parent = snapshot.revisions[0]! - const revision = { - id: 'revision-2', - parentRevisionId: parent.id, - restartedFromStepId: parent.steps[0]!.id, - createdAt: '2026-08-05T00:01:00.000Z', - steps: parent.steps.map((step, index) => ({ - ...structuredClone(step), - id: `step-v2-${index}`, - })), - characterInput: structuredClone(parent.characterInput), - characterOrigin: parent.characterOrigin, - characterId: parent.characterId, - outfitId: parent.outfitId, - characterSelectedAt: parent.characterSelectedAt, - actionInputs: {}, - } - snapshot.revisions.push(revision) - snapshot.currentRevisionId = revision.id - expect(isWorkflowRunSnapshot(snapshot)).toBe(true) - - revision.parentRevisionId = 'missing-revision' - expect(isWorkflowRunSnapshot(snapshot)).toBe(false) - }) - - it('ignores old or malformed local data instead of hydrating a partial run', async () => { - const storage = createMemoryStorage( - JSON.stringify({ version: WORKFLOW_RUN_STORAGE_VERSION - 1, runs: [createSnapshot()] }), - ) - const repository = createWorkflowRunRepository({ storage }) - await expect(repository.list()).resolves.toEqual([]) - - const malformed = createMemoryStorage( - JSON.stringify({ - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [{ id: 'partial-run', projectId: 'project-1' }], - }), - ) - const malformedRepository = createWorkflowRunRepository({ storage: malformed }) - await expect(malformedRepository.list()).resolves.toEqual([]) - }) -}) diff --git a/frontend/src/entities/workflow-run/store/workflow-run-store.ts b/frontend/src/entities/workflow-run/store/workflow-run-store.ts deleted file mode 100644 index cbb112d9..00000000 --- a/frontend/src/entities/workflow-run/store/workflow-run-store.ts +++ /dev/null @@ -1,475 +0,0 @@ -/** WorkflowRun 的异步持久化边界与当前 localStorage 适配器。 */ - -import type { - WorkflowGenerationRef, - WorkflowRevision, - WorkflowRunCharacterBinding, - WorkflowRunSnapshot, - WorkflowStep, -} from '../model' -import { - ACTION_FIRST_FRAME_CANDIDATE_COUNT, - WORKFLOW_GENERATION_ROLES, - WORKFLOW_RUN_KINDS, - WORKFLOW_RUN_STATUSES, - WORKFLOW_STEP_ORDERS, - WORKFLOW_STEP_PHASES, - WORKFLOW_STEP_STATUSES, - WORKFLOW_STEP_TYPES, -} from '../model/constants' - -export const WORKFLOW_RUN_STORAGE_KEY = 'windup.workflow-runs' -export const WORKFLOW_RUN_STORAGE_VERSION = 8 - -const ACTION_TYPES = ['walk', 'idle', 'attack', 'jump', 'custom'] as const -const CHARACTER_ORIGINS = ['generated', 'uploaded'] as const - -interface WorkflowRunStorage { - getItem(key: string): string | null - setItem(key: string, value: string): void -} - -/** - * 所有方法从一开始就是异步的,后续替换为 HTTP Repository 时调用方式不变。 - * 不提供 subscribe:Run 变化由发起操作的前端逻辑直接获知;后端任务进度由 Generation SSE 负责。 - */ -export interface WorkflowRunRepository { - create(run: WorkflowRunSnapshot): Promise - get(runId: WorkflowRunSnapshot['id']): Promise - getByCharacter(binding: WorkflowRunCharacterBinding): Promise - list(projectId?: string): Promise - /** expectedVersion 必须等于当前持久化版本;成功后返回 version + 1 的快照。 */ - save(run: WorkflowRunSnapshot, expectedVersion: number): Promise -} - -export interface CreateWorkflowRunRepositoryOptions { - storage?: WorkflowRunStorage | null -} - -interface PersistedWorkflowRuns { - version: typeof WORKFLOW_RUN_STORAGE_VERSION - runs: WorkflowRunSnapshot[] -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0 -} - -function isNullableString(value: unknown): value is string | null { - return typeof value === 'string' || value === null -} - -function isMember(value: unknown, members: readonly T[]): value is T { - return typeof value === 'string' && members.includes(value as T) -} - -function isGenerationRef(value: unknown): value is WorkflowGenerationRef { - return ( - isRecord(value) && - isNonEmptyString(value.taskId) && - isMember(value.role, WORKFLOW_GENERATION_ROLES) - ) -} - -function phaseMatchesStep(step: WorkflowStep): boolean { - if (step.status === 'passed') return step.phase === 'completed' - if (step.type === 'character') { - return step.phase === 'generating_character_candidates' || step.phase === 'selecting_character' - } - return [ - 'configuring_action', - 'generating_action_candidates', - 'selecting_action_frame', - 'generating_animation', - 'reviewing_animation', - 'exporting_action', - ].includes(step.phase) -} - -function hasValidGenerationRefs(step: WorkflowStep): boolean { - const taskIds = step.generations.map((generation) => generation.taskId) - if (new Set(taskIds).size !== taskIds.length) return false - if (step.type === 'character') { - const count = step.generations.length - return ( - step.generations.every((item) => item.role === 'character_candidates') && - count <= 1 && - (step.phase === 'generating_character_candidates' || - step.phase === 'completed' || - count === 1) - ) - } - - const candidateCount = step.generations.filter( - (item) => item.role === 'action_frame_candidate', - ).length - const animationCount = step.generations.filter((item) => item.role === 'animation').length - const rolesAreValid = - step.generations.every((item) => item.role !== 'character_candidates') && - candidateCount <= ACTION_FIRST_FRAME_CANDIDATE_COUNT && - animationCount <= 1 - if (!rolesAreValid) return false - if (step.phase === 'configuring_action') return candidateCount === 0 && animationCount === 0 - if (step.phase === 'generating_action_candidates') return animationCount === 0 - if (step.phase === 'selecting_action_frame') { - return candidateCount === ACTION_FIRST_FRAME_CANDIDATE_COUNT && animationCount === 0 - } - return candidateCount === ACTION_FIRST_FRAME_CANDIDATE_COUNT && animationCount === 1 -} - -function isWorkflowStep(value: unknown, expectedType: string): value is WorkflowStep { - if ( - !isRecord(value) || - !isNonEmptyString(value.id) || - !isNonEmptyString(value.nodeId) || - value.type !== expectedType || - !isMember(value.type, WORKFLOW_STEP_TYPES) || - !isMember(value.status, WORKFLOW_STEP_STATUSES) || - !isMember(value.phase, WORKFLOW_STEP_PHASES) || - !Array.isArray(value.generations) || - !value.generations.every(isGenerationRef) || - !isNullableString(value.error) - ) { - return false - } - - const step = value as unknown as WorkflowStep - const errorIsValid = step.status === 'failed' ? isNonEmptyString(step.error) : step.error === null - return errorIsValid && phaseMatchesStep(step) && hasValidGenerationRefs(step) -} - -function hasValidStepLine( - steps: WorkflowStep[], - expectedOrder: readonly string[], - rootNodeId: string, -): boolean { - if ( - steps.length < expectedOrder.length || - !steps.every((step, index) => - isWorkflowStep(step, index === 0 ? expectedOrder[0]! : 'action'), - ) || - new Set(steps.map((step) => step.id)).size !== steps.length || - new Set(steps.map((step) => step.nodeId)).size !== steps.length || - rootNodeId !== steps[0]?.nodeId - ) { - return false - } - - const character = steps[0]! - if (character.status === 'active' || character.status === 'failed') return steps.length === 1 - if (character.status !== 'passed') return false - - const actionSteps = steps.slice(1) - const activeActions = actionSteps.filter((step) => step.status === 'active') - return ( - activeActions.length <= 1 && - actionSteps.every((step) => ['active', 'passed', 'failed'].includes(step.status)) - ) -} - -function isWorkflowRevision( - value: unknown, - expectedOrder: readonly string[], - rootNodeId: string, -): value is WorkflowRevision { - return ( - isRecord(value) && - isNonEmptyString(value.id) && - isNullableString(value.parentRevisionId) && - isNullableString(value.restartedFromStepId) && - Array.isArray(value.steps) && - hasValidStepLine(value.steps as WorkflowStep[], expectedOrder, rootNodeId) && - hasValidInputs(value as unknown as WorkflowRevision) && - isNonEmptyString(value.createdAt) - ) -} - -function hasValidRevisions(run: WorkflowRunSnapshot): boolean { - const expectedOrder = WORKFLOW_STEP_ORDERS[run.source.key] - if ( - run.revisions.length === 0 || - !run.revisions.every((revision) => - isWorkflowRevision(revision, expectedOrder, run.source.rootNodeId), - ) || - new Set(run.revisions.map((revision) => revision.id)).size !== run.revisions.length || - new Set(run.revisions.flatMap((revision) => revision.steps.map((step) => step.id))).size !== - run.revisions.reduce((total, revision) => total + revision.steps.length, 0) - ) { - return false - } - - const current = run.revisions.find((revision) => revision.id === run.currentRevisionId) - if (!current) return false - const activeCount = current.steps.filter((step) => step.status === 'active').length - const characterPassed = current.steps[0]?.status === 'passed' - if (run.status === 'completed' && (activeCount !== 0 || !characterPassed)) return false - if ( - run.status === 'failed' && - (activeCount !== 0 || !current.steps.some((step) => step.status === 'failed')) - ) { - return false - } - if ((run.status === 'active' || run.status === 'interrupted') && activeCount !== 1) return false - - return run.revisions.every((revision, index) => { - if (index === 0) { - return revision.parentRevisionId === null && revision.restartedFromStepId === null - } - const parentIndex = run.revisions.findIndex( - (candidate) => candidate.id === revision.parentRevisionId, - ) - if (parentIndex < 0 || parentIndex >= index || revision.restartedFromStepId === null) - return false - return run.revisions[parentIndex]!.steps.some( - (step) => step.id === revision.restartedFromStepId, - ) - }) -} - -function isMediaReference(value: unknown): boolean { - // MediaReference 在 Entity 层是品牌字符串;Repository 只校验可持久化表示。 - return isNonEmptyString(value) -} - -function hasValidInputs(revision: WorkflowRevision): boolean { - const characterInput = revision.characterInput as unknown - const characterInputValid = - characterInput === null || - (isRecord(characterInput) && - isNonEmptyString(characterInput.prompt) && - Array.isArray(characterInput.referenceMedia) && - characterInput.referenceMedia.every(isMediaReference) && - typeof characterInput.spriteWidth === 'number' && - Number.isSafeInteger(characterInput.spriteWidth) && - characterInput.spriteWidth > 0 && - typeof characterInput.spriteHeight === 'number' && - Number.isSafeInteger(characterInput.spriteHeight) && - characterInput.spriteHeight > 0) - if (!characterInputValid) return false - - const characterIsEmpty = - revision.characterOrigin === null && - revision.characterId === null && - revision.outfitId === null && - revision.characterSelectedAt === null - const characterIsSelected = - isMember(revision.characterOrigin, CHARACTER_ORIGINS) && - isNonEmptyString(revision.characterId) && - isNonEmptyString(revision.outfitId) && - isNonEmptyString(revision.characterSelectedAt) - if (!characterIsEmpty && !characterIsSelected) return false - - const characterStep = revision.steps.find((step) => step.type === 'character') - const characterGenerationCount = characterStep?.generations.filter( - (generation) => generation.role === 'character_candidates', - ).length - if ( - characterIsSelected && - ((revision.characterOrigin === 'generated' && characterGenerationCount !== 1) || - (revision.characterOrigin === 'uploaded' && characterGenerationCount !== 0)) - ) { - return false - } - - if (revision.characterInput === null || !isRecord(revision.actionInputs)) return false - const actionSteps = revision.steps.filter((step) => step.type === 'action') - const actionStepIds = new Set(actionSteps.map((step) => step.id)) - const entries = Object.entries(revision.actionInputs) - if ( - entries.some( - ([stepId, input]) => - !actionStepIds.has(stepId) || - !isRecord(input) || - !isNullableString(input.actionId) || - !isNonEmptyString(input.name) || - !isMember(input.type, ACTION_TYPES) || - !isNullableString(input.prompt) || - typeof input.fps !== 'number' || - !Number.isFinite(input.fps) || - input.fps <= 0, - ) - ) { - return false - } - - if (characterIsEmpty) { - return entries.length === 0 && actionSteps.every((step) => step.status === 'locked') - } - if (!characterIsSelected) return false - return actionSteps.every( - (step) => - step.status === 'locked' || - (step.status === 'active' && step.phase === 'configuring_action') || - Object.hasOwn(revision.actionInputs, step.id), - ) -} - -export function isWorkflowRunSnapshot(value: unknown): value is WorkflowRunSnapshot { - if ( - !isRecord(value) || - !isNonEmptyString(value.id) || - !isNonEmptyString(value.projectId) || - typeof value.version !== 'number' || - !Number.isSafeInteger(value.version) || - value.version < 1 || - !isRecord(value.source) || - value.source.type !== 'builtin' || - !isMember(value.source.key, WORKFLOW_RUN_KINDS) || - !isNonEmptyString(value.source.rootNodeId) || - !isMember(value.status, WORKFLOW_RUN_STATUSES) || - !isNonEmptyString(value.currentRevisionId) || - !Array.isArray(value.revisions) || - !isNonEmptyString(value.createdAt) || - !isNonEmptyString(value.updatedAt) - ) { - return false - } - - const run = value as unknown as WorkflowRunSnapshot - return hasValidRevisions(run) -} - -function readPersistedRuns(storage: WorkflowRunStorage | null): WorkflowRunSnapshot[] { - if (storage === null) return [] - try { - const raw = storage.getItem(WORKFLOW_RUN_STORAGE_KEY) - if (!raw) return [] - const value: unknown = JSON.parse(raw) - if ( - !isRecord(value) || - value.version !== WORKFLOW_RUN_STORAGE_VERSION || - !Array.isArray(value.runs) || - !value.runs.every(isWorkflowRunSnapshot) - ) { - return [] - } - return structuredClone(value.runs) - } catch { - return [] - } -} - -function resolveBrowserStorage(): WorkflowRunStorage | null { - if (typeof window === 'undefined') return null - try { - return window.localStorage - } catch { - return null - } -} - -/** 当前本地实现;业务层只依赖异步 Repository 接口。 */ -export function createWorkflowRunRepository( - options: CreateWorkflowRunRepositoryOptions = {}, -): WorkflowRunRepository { - const storage = options.storage === undefined ? resolveBrowserStorage() : options.storage - const runs = new Map(readPersistedRuns(storage).map((run) => [run.id, run] as const)) - - function reload(): void { - if (storage === null) return - runs.clear() - for (const run of readPersistedRuns(storage)) runs.set(run.id, run) - } - - function persist(): void { - const payload: PersistedWorkflowRuns = { - version: WORKFLOW_RUN_STORAGE_VERSION, - runs: [...runs.values()], - } - storage?.setItem(WORKFLOW_RUN_STORAGE_KEY, JSON.stringify(payload)) - } - - async function create(run: WorkflowRunSnapshot): Promise { - if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') - reload() - if (runs.has(run.id)) throw new Error(`WorkflowRun 已存在:${run.id}`) - assertUniqueCharacterBinding(run, runs) - const saved = structuredClone(run) - runs.set(saved.id, saved) - try { - persist() - } catch (cause) { - runs.delete(saved.id) - throw new Error('WorkflowRun 本地持久化失败', { cause }) - } - return structuredClone(saved) - } - - async function save( - run: WorkflowRunSnapshot, - expectedVersion: number, - ): Promise { - if (!isWorkflowRunSnapshot(run)) throw new TypeError('Invalid WorkflowRun snapshot') - if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) { - throw new TypeError('expectedVersion 必须是正整数') - } - reload() - const previous = runs.get(run.id) - if (!previous) throw new Error(`WorkflowRun 不存在:${run.id}`) - if (run.version !== expectedVersion || previous.version !== expectedVersion) { - throw new Error('WorkflowRun 已被其他操作更新,请刷新后重试') - } - assertUniqueCharacterBinding(run, runs) - const saved = { ...structuredClone(run), version: expectedVersion + 1 } - runs.set(saved.id, saved) - try { - persist() - } catch (cause) { - runs.set(previous.id, previous) - throw new Error('WorkflowRun 本地持久化失败', { cause }) - } - return structuredClone(saved) - } - - return { - create, - async get(runId) { - reload() - const run = runs.get(runId) - return run ? structuredClone(run) : null - }, - async getByCharacter(binding) { - reload() - if (!binding.projectId.trim() || !binding.characterId.trim()) { - throw new TypeError('查询 WorkflowRun 必须提供项目和角色 ID') - } - const matches = [...runs.values()] - .filter((candidate) => candidate.projectId === binding.projectId) - .filter((candidate) => { - const revision = candidate.revisions.find( - (item) => item.id === candidate.currentRevisionId, - ) - return revision?.characterId === binding.characterId - }) - if (matches.length > 1) throw new Error('同一个 Character 绑定了多条 WorkflowRun') - return matches[0] ? structuredClone(matches[0]) : null - }, - async list(projectId) { - reload() - return [...runs.values()] - .filter((run) => projectId === undefined || run.projectId === projectId) - .map((run) => structuredClone(run)) - }, - save, - } -} - -/** 同一个项目中的 Character 只能绑定一条 WorkflowRun。 */ -function assertUniqueCharacterBinding( - run: WorkflowRunSnapshot, - runs: ReadonlyMap, -): void { - const revision = run.revisions.find((item) => item.id === run.currentRevisionId) - if (!revision?.characterId) return - - const duplicate = [...runs.values()].find((candidate) => { - if (candidate.id === run.id || candidate.projectId !== run.projectId) return false - const current = candidate.revisions.find((item) => item.id === candidate.currentRevisionId) - return current?.characterId === revision.characterId - }) - if (duplicate) throw new Error('这个 Character 已经绑定到另一条 WorkflowRun') -} diff --git a/frontend/src/features/character-setup/index.test.ts b/frontend/src/features/character-setup/index.test.ts deleted file mode 100644 index 72995a08..00000000 --- a/frontend/src/features/character-setup/index.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expectTypeOf, it } from 'vitest' - -import type { WorkflowCharacterInput } from '@/entities' -import type { CharacterSetupProps } from '.' - -it('submits WorkflowRun character setup input', () => { - expectTypeOf() - .parameter(0) - .toEqualTypeOf() -}) diff --git a/frontend/src/features/character-setup/index.ts b/frontend/src/features/character-setup/index.ts index 87d909b5..44c9a7b0 100644 --- a/frontend/src/features/character-setup/index.ts +++ b/frontend/src/features/character-setup/index.ts @@ -1,7 +1,7 @@ -import type { WorkflowCharacterInput } from '@/entities' +import type { CreateCharacterInput } from '@/entities' /** 填写角色资料并提交母版生成。 */ export interface CharacterSetupProps { projectId: string - onSubmit(input: WorkflowCharacterInput): void + onSubmit(input: CreateCharacterInput): void } 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 index fe5016b4..868cf3c5 100644 --- a/frontend/src/features/workflow-controller/README.md +++ b/frontend/src/features/workflow-controller/README.md @@ -1,28 +1,20 @@ # WorkflowController -WorkflowController 是页面命令适配层,不是 WorkflowRun 的父级,也不保存第二套状态。 +WorkflowController 是页面共用的流程协调器。Quick Start 可以自动连续调用它,Workflow Editor 可以按 +用户点击逐步调用它;两种页面不能各自维护另一套状态机。 -```text -Quick Start --------┐ - ├─> WorkflowController -> WorkflowRunService -Workflow Editor ----┘ -> Repository - -> Generation / Character APIs -``` +## 职责 -## 负责什么 +- 从 `WorkflowRunStore` 读取和保存同一个 `WorkflowRun`。 +- 调用 Generation APIs 创建角色母版和动作生成任务,并处理刷新恢复。 +- 调用纯状态函数推进 Step、追加动作、审核、暂停和从历史节点重启。 +- 审核通过后调用发布能力写入正式 Character 资产。 -- 把页面动作转成统一命令,例如创建 Run、确认角色、追加动作、确认首帧和批准动作。 -- 每次根据 `runId` 从 WorkflowRunService 获取绑定实例,再调用实体方法。 -- 在中断、继续、动作配置和 Revision 重启后保存快照。 -- 按 `projectId + characterId` 找到角色唯一的 WorkflowRun;新增动作追加到原 Run。 +Controller 不定义第二种 WorkflowRun,不负责 Playtest 状态,也不直接调用模型供应商。 -## 不负责什么 +## 文件 -- 不管理 Quick Start、Workflow Editor 或 Playtest 的页面状态。 -- 不直接访问 localStorage,不提供 `subscribe` / `subscribeAll`。 -- 不解释 SSE 消息,不整理模型提示词,不调用模型供应商。 -- 不生成资产 ID,也不把 WorkflowRun 临时结果伪装成 Character。 - -Quick Start 会自动连续调用 Controller;Workflow Editor 等用户逐步点击后再调用。两者是同级 -页面适配器,不能互相注入方法。Controller 的所有读取和命令均为异步接口,后续 Repository -从 localStorage 换成后端 HTTP 时,页面调用方式不需要改变。 +- `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..c045bbb2 --- /dev/null +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -0,0 +1,249 @@ +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 index 54fb4fd1..9f0a523a 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -1,111 +1,882 @@ import { describe, expect, it, vi } from 'vitest' -import type { WorkflowRun, WorkflowRunService, WorkflowRunSnapshot } from '@/entities' -import { createWorkflowController } from './controller' +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 '.' -function snapshot(status: WorkflowRunSnapshot['status'] = 'completed'): WorkflowRunSnapshot { +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: 'run-1', + id: 'task-1', projectId: 'project-1', - version: 1, - source: { type: 'builtin', key: 'character_action', rootNodeId: 'character-node' }, - status, - currentRevisionId: 'revision-1', - revisions: [ + 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: 'revision-1', - parentRevisionId: null, - restartedFromStepId: null, - createdAt: '2026-08-06T00:00:00.000Z', - steps: [ - { - id: 'character-step', - nodeId: 'character-node', - type: 'character', - status: 'passed', - phase: 'completed', - generations: [], - error: null, - }, - ], - characterInput: { - prompt: '守夜人', - referenceMedia: [], - spriteWidth: 256, - spriteHeight: 256, - }, - characterOrigin: 'generated', + id: 'outfit-1', characterId: 'character-1', - outfitId: 'outfit-1', - characterSelectedAt: '2026-08-06T00:00:00.000Z', - actionInputs: {}, + name: '默认造型', + description: null, + previewUrl: 'https://example.com/knight.png', + actions: [], }, ], - createdAt: '2026-08-06T00:00:00.000Z', - updatedAt: '2026-08-06T00:00:00.000Z', } -} + 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, + }) -function createRun(state = snapshot()): WorkflowRun { - let current = structuredClone(state) return { - id: current.id, - snapshot: () => structuredClone(current), - save: vi.fn(async () => structuredClone(current)), - interrupt: vi.fn(() => { - current.status = 'interrupted' - return structuredClone(current) - }), - continue: vi.fn(() => structuredClone(current)), - restartFromStep: vi.fn(() => structuredClone(current)), - start: vi.fn(), - resumeCharacterCandidates: vi.fn(), - confirmCharacter: vi.fn(), - acceptUploadedCharacterTemplate: vi.fn(), - configureAction: vi.fn(), - appendAction: vi.fn(), - resumeActionFirstFrameCandidates: vi.fn(), - confirmActionFirstFrame: vi.fn(), - resumeAction: vi.fn(), - getActionReview: vi.fn(), - approveAction: vi.fn(), + 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 service(run = createRun()): WorkflowRunService { - return { - create: vi.fn(async () => run), - get: vi.fn(async () => run), - getByCharacter: vi.fn(async () => run), - appendAction: vi.fn(async () => run), +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 } -describe('WorkflowController', () => { - it('delegates creation and character lookup to the shared WorkflowRunService', async () => { - const workflowService = service() - const controller = createWorkflowController({ workflowService }) +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', + }) + }) - await expect( - controller.create({ projectId: 'project-1', characterPrompt: '守夜人' }), - ).resolves.toMatchObject({ id: 'run-1' }) - await expect( - controller.getByCharacter({ projectId: 'project-1', characterId: 'character-1' }), - ).resolves.toMatchObject({ id: 'run-1' }) - expect(workflowService.create).toHaveBeenCalledTimes(1) - expect(workflowService.getByCharacter).toHaveBeenCalledTimes(1) + 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 local interrupt and restart mutations exactly once', async () => { - const run = createRun(snapshot('active')) - const controller = createWorkflowController({ workflowService: service(run) }) + 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') + }) - await expect(controller.interrupt('run-1')).resolves.toMatchObject({ status: 'interrupted' }) - expect(run.interrupt).toHaveBeenCalledTimes(1) - expect(run.save).toHaveBeenCalledTimes(1) + 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`) - await controller.restartAction('run-1', 'action-step') - expect(run.restartFromStep).toHaveBeenCalledWith('action-step') - expect(run.save).toHaveBeenCalledTimes(2) + 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 index a9e6ccbd..dc473267 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -1,120 +1,484 @@ import type { - AcceptUploadedCharacterTemplateInput, - ActionFirstFrameCandidateBatch, - ActionReviewResult, - AppendWorkflowActionInput, - CharacterCandidateBatch, - ConfigureWorkflowActionInput, - CreateWorkflowRunInput, - PublishActionResult, + CharacterApis, + CharacterSetupStepInput, + CompleteAnimationGenerationInput, + CompleteAnimationGenerationResult, + GenerationApis, + MediaReference, WorkflowRun, - WorkflowRunCharacterBinding, - WorkflowRunService, - WorkflowRunSnapshot, + 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 -/** - * 页面级流程协调器。 - * - * 它不保存第二份 WorkflowRun,也不解释生成任务。每次命令都先从同一个 Service 取得 - * 绑定 Run,再调用领域方法。Quick Start 自动连续调用这些命令,Workflow Editor 则等 - * 用户逐步点击;两种页面因此共享完全相同的状态转换规则。 - */ export interface WorkflowController { - create(input: CreateWorkflowRunInput): Promise - get(runId: WorkflowRunSnapshot['id']): Promise - getByCharacter(binding: WorkflowRunCharacterBinding): Promise - start(runId: string): Promise - resumeCharacterCandidates(runId: string): Promise - confirmCharacter(runId: string, selectedImageUrl: string): Promise + /** 创建并保存一条纯前端运行记录。 */ + 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: string, - input: AcceptUploadedCharacterTemplateInput, - ): Promise - appendAction(input: AppendWorkflowActionInput): Promise - configureAction(runId: string, input: ConfigureWorkflowActionInput): Promise - resumeActionFirstFrameCandidates(runId: string): Promise - confirmActionFirstFrame(runId: string, selectedImageUrl: string): Promise - resumeAction(runId: string): Promise - getActionReview(runId: string): Promise - approveAction(runId: string): Promise - interrupt(runId: string): Promise - continue(runId: string): Promise - restartAction(runId: string, stepId: string): Promise + 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 { - workflowService: WorkflowRunService + store: WorkflowRunStore + generationApis: GenerationApis + /** 创建角色流程需要该接口;只操作已有角色动作时可不配置。 */ + characterApis?: CharacterApis + /** 测试可注入确定性 ID;生产默认使用浏览器随机 UUID。 */ + createId?: (scope: 'run' | 'revision' | 'submission') => string + /** 测试可注入确定性时间。 */ + now?: () => string } +/** + * Quick Start 与手动工作流共用的流程协调器。 + * + * Controller 只负责读取当前步骤、保存状态并委派角色图任务;纯状态转换和异步任务 + * 生命周期分别留在本 Feature 的内部模块。生产接入必须复用同一个 Controller 实例, + * 不能在组件渲染期间重复创建。 + */ export function createWorkflowController({ - workflowService, + store, + generationApis, + characterApis, + createId = createRuntimeId, + now = () => new Date().toISOString(), }: CreateWorkflowControllerOptions): WorkflowController { - async function requireRun(runId: string): Promise { - const run = await workflowService.get(runId) + 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 { - async create(input) { - return (await workflowService.create(input)).snapshot() - }, - async get(runId) { - return (await workflowService.get(runId))?.snapshot() ?? null - }, - async getByCharacter(binding) { - return (await workflowService.getByCharacter(binding))?.snapshot() ?? null - }, - async start(runId) { - return (await requireRun(runId)).start() - }, - async resumeCharacterCandidates(runId) { - return (await requireRun(runId)).resumeCharacterCandidates() - }, - async confirmCharacter(runId, selectedImageUrl) { - return (await requireRun(runId)).confirmCharacter(selectedImageUrl) - }, - async acceptUploadedCharacterTemplate(runId, input) { - return (await requireRun(runId)).acceptUploadedCharacterTemplate(input) - }, - async appendAction(input) { - return (await workflowService.appendAction(input)).snapshot() - }, - async configureAction(runId, input) { - const run = await requireRun(runId) - run.configureAction(input) - return run.save() - }, - async resumeActionFirstFrameCandidates(runId) { - return (await requireRun(runId)).resumeActionFirstFrameCandidates() - }, - async confirmActionFirstFrame(runId, selectedImageUrl) { - return (await requireRun(runId)).confirmActionFirstFrame(selectedImageUrl) - }, - async resumeAction(runId) { - return (await requireRun(runId)).resumeAction() - }, - async getActionReview(runId) { - return (await requireRun(runId)).getActionReview() - }, - async approveAction(runId) { - return (await requireRun(runId)).approveAction() - }, - async interrupt(runId) { - const run = await requireRun(runId) - run.interrupt() - return run.save() - }, - async continue(runId) { - const run = await requireRun(runId) - run.continue() - return run.save() - }, - async restartAction(runId, stepId) { - const run = await requireRun(runId) - run.restartFromStep(stepId) - return run.save() - }, + 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 542dbad1..fcb6978b 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,2 +1,6 @@ export { createWorkflowController } from './controller' -export type { CreateWorkflowControllerOptions, WorkflowController } 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}` +} diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index 2721ab3e..eae909b7 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -29,9 +29,10 @@ export function HomePage() { {/* - 首屏三段式文案是 WorkflowRun 两张业务卡片的产品化表达: - “确认角色”对应 character 卡片,“生成动作”和“检查交付”对应 action 卡片内部 phase。 - phase 变化时要同步检查这段用户文案,但首页不展示内部状态名称。 + 首屏用的三段式说法,是 entities/workflow-run 那八步 WORKFLOW_STEP_ORDER 的粗粒度概括: + 确认角色 = character-setup + character-template,生成动作 = first-frame + complete-animation, + 检查交付 = review + export;template-candidate 与 action-setup 是流程内部环节,首屏不提。 + 这份对应关系目前只写在这里,八步一变这段文案不会跟着变,改流程时要一并改。 */}
      Date: Thu, 6 Aug 2026 18:31:42 +0800 Subject: [PATCH 27/27] style(frontend): format action generation task --- .../features/workflow-controller/action-generation-task.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/workflow-controller/action-generation-task.ts b/frontend/src/features/workflow-controller/action-generation-task.ts index c045bbb2..7d9ec16b 100644 --- a/frontend/src/features/workflow-controller/action-generation-task.ts +++ b/frontend/src/features/workflow-controller/action-generation-task.ts @@ -163,7 +163,10 @@ export function createActionGenerationTask({ const completeResult = result as CompleteAnimationGenerationResult const frameCountError = getCompleteAnimationFrameCountError(completeResult) return save( - completeActionGenerationState(latest, frameCountError ? { error: frameCountError } : completeResult), + completeActionGenerationState( + latest, + frameCountError ? { error: frameCountError } : completeResult, + ), ) }