diff --git a/frontend/src/features/quick-start-agent/boundaries.test.ts b/frontend/src/features/quick-start-agent/boundaries.test.ts index 0d55f2c9..1147b268 100644 --- a/frontend/src/features/quick-start-agent/boundaries.test.ts +++ b/frontend/src/features/quick-start-agent/boundaries.test.ts @@ -1,5 +1,5 @@ import { readFileSync, readdirSync } from 'node:fs' -import { extname, join, resolve } from 'node:path' +import { basename, extname, join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' const featureDirectory = resolve(import.meta.dirname) @@ -22,7 +22,7 @@ describe('quick-start-agent architecture boundary', () => { it('keeps business imports out of the Agent core', () => { const forbidden = ['@/pages', '@/entities', '@/shared/api', '@/features'] const featureDependencies = productionFiles(featureDirectory) - .filter((file) => !file.endsWith('/production.ts')) + .filter((file) => basename(file) !== 'production.ts') .flatMap((file) => moduleSpecifiers(readFileSync(file, 'utf8'))) expect( diff --git a/frontend/src/features/workflow-controller/existing-character-action.test.ts b/frontend/src/features/workflow-controller/existing-character-action.test.ts new file mode 100644 index 00000000..b3579951 --- /dev/null +++ b/frontend/src/features/workflow-controller/existing-character-action.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Character, CharacterApis, WorkflowRunApis } from '@/entities' +import { createExistingCharacterActionRun } from './existing-character-action' + +const character: Character = { + id: '51', + projectId: '42', + workflowRunId: 'original-run', + name: '轻装信使', + description: '戴兜帽的像素信使', + referenceImageUrl: '/reference.png', + dataVersion: 1, + status: 1, + templates: [ + { + direction: 'east', + sourceDirection: null, + mirrorX: false, + imageUrl: '/master.png', + }, + { + direction: 'west', + sourceDirection: 'east', + mirrorX: true, + imageUrl: null, + }, + { + direction: 'north', + sourceDirection: null, + mirrorX: false, + imageUrl: '/master-north.png', + }, + ], + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: null, + previewUrl: '/master.png', + model3dUrl: null, + actions: [], + }, + ], +} + +describe('createExistingCharacterActionRun', () => { + it('creates a new run with the existing character and outfit template already completed', async () => { + const create = vi.fn(async (input) => ({ + id: 'new-run', + projectId: input.projectId, + version: 1, + storageStatus: 'active' as const, + nodes: input.nodes, + })) + + const result = await createExistingCharacterActionRun( + { characterId: '51', outfitId: 'outfit-default' }, + { + characterApis: { get: vi.fn(async () => character) } as Pick, + workflowRunApis: { create } as Pick, + }, + ) + + expect(result.run.id).toBe('new-run') + expect(create).toHaveBeenCalledWith({ + projectId: '42', + nodes: [ + expect.objectContaining({ + type: 'character-setup', + status: 'passed', + input: expect.objectContaining({ + characterId: '51', + referenceMedia: ['/master.png', '/master-north.png'], + }), + }), + expect.objectContaining({ + type: 'character-template', + status: 'passed', + selectedImageUrl: '/master.png', + selectedImages: { east: '/master.png', north: '/master-north.png' }, + }), + ], + }) + }) + + it('refuses to guess another outfit or template', async () => { + const dependencies = { + characterApis: { get: vi.fn(async () => character) } as Pick, + workflowRunApis: { create: vi.fn() } as Pick, + } + + await expect( + createExistingCharacterActionRun({ characterId: '51', outfitId: 'missing' }, dependencies), + ).rejects.toThrow('当前造型还没有可用的角色母版,请先完成定妆再生成动作') + expect(dependencies.workflowRunApis.create).not.toHaveBeenCalled() + }) + + it.each([ + [{ ...character, description: null }, '轻装信使'], + [{ ...character, name: null, description: null }, '现有角色'], + ])('keeps a usable identity prompt when description is absent', async (candidate, prompt) => { + const create = vi.fn(async (input) => ({ + id: 'new-run', + projectId: input.projectId, + version: 1, + storageStatus: 'active' as const, + nodes: input.nodes, + })) + + await createExistingCharacterActionRun( + { characterId: candidate.id, outfitId: 'outfit-default' }, + { + characterApis: { get: vi.fn(async () => candidate) } as Pick, + workflowRunApis: { create } as Pick, + }, + ) + + expect(create.mock.calls[0]?.[0].nodes[0]).toMatchObject({ input: { prompt } }) + }) +}) diff --git a/frontend/src/features/workflow-controller/existing-character-action.ts b/frontend/src/features/workflow-controller/existing-character-action.ts new file mode 100644 index 00000000..2f4358a7 --- /dev/null +++ b/frontend/src/features/workflow-controller/existing-character-action.ts @@ -0,0 +1,81 @@ +import type { + Character, + CharacterApis, + MediaReference, + Outfit, + WorkflowNode, + WorkflowRun, + WorkflowRunApis, +} from '@/entities' +import { characterTemplateImages } from '@/entities' + +const MISSING_TEMPLATE_MESSAGE = '当前造型还没有可用的角色母版,请先完成定妆再生成动作' + +export interface ExistingCharacterActionTarget { + readonly characterId: Character['id'] + readonly outfitId: Outfit['id'] +} + +interface ExistingCharacterActionDependencies { + readonly characterApis: Pick + readonly workflowRunApis: Pick +} + +export interface ExistingCharacterActionRun { + readonly run: WorkflowRun + readonly character: Character + readonly outfit: Outfit +} + +function existingCharacterNodes(character: Character, outfit: Outfit): WorkflowNode[] { + const selectedImages = characterTemplateImages(character.templates) + const templateUrl = selectedImages.east ?? outfit.previewUrl ?? character.referenceImageUrl + if (!templateUrl) throw new Error(MISSING_TEMPLATE_MESSAGE) + if (!selectedImages.east) selectedImages.east = templateUrl + const prompt = character.description?.trim() || character.name?.trim() || '现有角色' + const referenceMedia = [...new Set(Object.values(selectedImages))] as MediaReference[] + + return [ + { + id: 'character-setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { + characterId: character.id, + prompt, + referenceMedia, + }, + }, + { + id: 'character-template', + type: 'character-template', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: ['character-setup'], + generations: [], + error: null, + selectedImageUrl: templateUrl, + selectedImages, + }, + ] +} + +/** 为现有角色的新动作创建独立 Run;原角色创建 Run 保持只读。 */ +export async function createExistingCharacterActionRun( + target: ExistingCharacterActionTarget, + dependencies: ExistingCharacterActionDependencies, +): Promise { + const character = await dependencies.characterApis.get(target.characterId) + const outfit = character.outfits.find((candidate) => candidate.id === target.outfitId) + if (!outfit) throw new Error(MISSING_TEMPLATE_MESSAGE) + + const run = await dependencies.workflowRunApis.create({ + projectId: character.projectId, + nodes: existingCharacterNodes(character, outfit), + }) + return { run, character, outfit } +} diff --git a/frontend/src/features/workflow-controller/index.ts b/frontend/src/features/workflow-controller/index.ts index a2f1f5d0..231ca359 100644 --- a/frontend/src/features/workflow-controller/index.ts +++ b/frontend/src/features/workflow-controller/index.ts @@ -1,4 +1,5 @@ export { createAutoPrepareProject, createWorkflowController } from './controller' +export { createExistingCharacterActionRun } from './existing-character-action' export type { AddActionInput, ApplyGenerationResultInput, @@ -10,3 +11,7 @@ export type { StartCharacterGenerationResult, WorkflowController, } from './controller' +export type { + ExistingCharacterActionRun, + ExistingCharacterActionTarget, +} from './existing-character-action' diff --git a/frontend/src/pages/character-detail/index.test.tsx b/frontend/src/pages/character-detail/index.test.tsx index 57972092..fdc23bee 100644 --- a/frontend/src/pages/character-detail/index.test.tsx +++ b/frontend/src/pages/character-detail/index.test.tsx @@ -1,14 +1,16 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' import { AppRoutes } from '@/app' +import { workflowRunApis } from '@/entities' import { AuthenticatedAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllEnvs() vi.unstubAllGlobals() }) @@ -51,7 +53,7 @@ describe('CharacterDetailPage', () => { expect(preview.getAttribute('decoding')).toBe('async') } expect(screen.queryByText('GIF')).toBeNull() - expect(screen.queryByRole('button', { name: '增加动作' })).toBeNull() + expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(false) const exportEntry = screen.getByRole('button', { name: '导出资产包' }) expect(exportEntry.className).toContain('rounded-full') expect(screen.queryByText('当前阶段')).toBeNull() @@ -62,6 +64,74 @@ describe('CharacterDetailPage', () => { expect(playtestEntry.parentElement?.className).toContain('items-start') }) + it('lets the user choose Quick Start or Workflow Editor for the selected outfit', async () => { + renderCharacter('51') + await screen.findByRole('heading', { name: '轻装信使' }) + + fireEvent.click(screen.getByRole('button', { name: '增加动作' })) + + expect(screen.getByRole('dialog', { name: '选择动作创建方式' })).toBeTruthy() + expect(screen.getByRole('link', { name: '使用 Quick Start' }).getAttribute('href')).toBe( + '/quick-start?characterId=51&outfitId=outfit-default', + ) + expect(screen.getByRole('button', { name: '使用 Workflow Editor' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '关闭动作创建方式' })) + expect(screen.queryByRole('dialog', { name: '选择动作创建方式' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '增加动作' })) + const dialog = screen.getByRole('dialog', { name: '选择动作创建方式' }) + fireEvent.mouseDown(dialog.parentElement!) + expect(screen.queryByRole('dialog', { name: '选择动作创建方式' })).toBeNull() + }) + + it('keeps the chooser open and reports Workflow Editor creation failures', async () => { + vi.spyOn(workflowRunApis, 'create').mockRejectedValue(new Error('工作流暂时不可用')) + renderCharacter('51') + await screen.findByRole('heading', { name: '轻装信使' }) + + fireEvent.click(screen.getByRole('button', { name: '增加动作' })) + fireEvent.click(screen.getByRole('button', { name: '使用 Workflow Editor' })) + + expect(await screen.findByRole('alert')).toHaveProperty('textContent', '工作流暂时不可用') + expect(screen.getByRole('dialog', { name: '选择动作创建方式' })).toBeTruthy() + expect( + screen.getByRole('button', { name: '使用 Workflow Editor' }).hasAttribute('disabled'), + ).toBe(false) + }) + + it('creates an independent template-bound run before opening Workflow Editor', async () => { + const create = vi.spyOn(workflowRunApis, 'create').mockImplementation(async (input) => ({ + id: 'new-action-run', + projectId: input.projectId, + version: 1, + storageStatus: 'active', + nodes: input.nodes, + })) + renderCharacter('51') + await screen.findByRole('heading', { name: '轻装信使' }) + + fireEvent.click(screen.getByRole('button', { name: '增加动作' })) + fireEvent.click(screen.getByRole('button', { name: '使用 Workflow Editor' })) + + await waitFor(() => expect(create).toHaveBeenCalledOnce()) + expect(create).toHaveBeenCalledWith({ + projectId: '42', + nodes: expect.arrayContaining([ + expect.objectContaining({ + type: 'character-setup', + status: 'passed', + input: expect.objectContaining({ characterId: '51' }), + }), + expect.objectContaining({ + type: 'character-template', + status: 'passed', + selectedImageUrl: 'https://cdn.windup.test/messenger-outfit.png', + }), + ]), + }) + }) + it('expands an Action into backend Frames sorted by index', async () => { renderCharacter('51') @@ -82,6 +152,43 @@ describe('CharacterDetailPage', () => { expect(scroller?.querySelector('ol')?.className).toContain('min-w-max') }) + it('allows adding an action when the character has directional templates without an outfit preview', async () => { + const backend = createProjectAssetsBackend() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const response = await backend.fetch(input, init) + if (!request.url.endsWith('/characters/52')) return response + + const payload = (await response.json()) as { + data: { character_data: { templates?: unknown[] } } + [key: string]: unknown + } + payload.data.character_data.templates = [ + { + direction: 'east', + source_direction: null, + mirror_x: false, + image_url: 'https://cdn.windup.test/draft-east.png', + }, + ] + return new Response(JSON.stringify(payload), { + headers: { 'content-type': 'application/json' }, + }) + }) + + render( + + + + + , + ) + + expect(await screen.findByRole('heading', { name: '待定角色' })).toBeTruthy() + expect(screen.getByRole('button', { name: '增加动作' }).hasAttribute('disabled')).toBe(false) + }) + it('preserves the Outfit level when no Action exists', async () => { renderCharacter('52') @@ -89,6 +196,9 @@ describe('CharacterDetailPage', () => { expect(screen.queryByRole('combobox', { name: '选择造型' })).toBeNull() expect(screen.getByText('这个造型还没有动作')).toBeTruthy() expect(screen.queryByRole('link', { name: '在预览台打开当前造型' })).toBeNull() + const addAction = screen.getByRole('button', { name: '增加动作' }) + expect(addAction.hasAttribute('disabled')).toBe(true) + expect(addAction.getAttribute('title')).toBe('当前造型缺少角色母版') }) it('renders a real empty state when the Character has no Outfit', async () => { diff --git a/frontend/src/pages/character-detail/index.tsx b/frontend/src/pages/character-detail/index.tsx index 9a33d485..ab90c3de 100644 --- a/frontend/src/pages/character-detail/index.tsx +++ b/frontend/src/pages/character-detail/index.tsx @@ -1,8 +1,18 @@ import { useEffect, useMemo, useState } from 'react' -import { Link, useOutletContext, useParams } from 'react-router' +import { Graph, Lightning, Plus, X } from '@phosphor-icons/react' +import { Link, useNavigate, useOutletContext, useParams } from 'react-router' -import { characterApis, type Action, type Character, type Outfit, type Project } from '@/entities' +import { + characterTemplateImages, + characterApis, + workflowRunApis, + type Action, + type Character, + type Outfit, + type Project, +} from '@/entities' import { createCharacterExportModel, ExportButton } from '@/features/export-package' +import { createExistingCharacterActionRun } from '@/features/workflow-controller' import { AssetPreviewSurface } from '@/shared/ui' const ACTION_TYPE_LABELS: Record = { @@ -123,7 +133,7 @@ export function CharacterDetailPage() {
- + )} @@ -198,15 +208,131 @@ function OutfitMaster({ character, outfit }: { character: Character; outfit: Out ) } -function ActionList({ outfit }: { outfit: Outfit }) { +function ActionList({ character, outfit }: { character: Character; outfit: Outfit }) { + const navigate = useNavigate() const [selectedActionId, setSelectedActionId] = useState(null) + const [entryOpen, setEntryOpen] = useState(false) + const [creatingEditor, setCreatingEditor] = useState(false) + const [entryError, setEntryError] = useState(null) const selectedAction = outfit.actions.find((action) => action.id === selectedActionId) ?? null + const templateImages = characterTemplateImages(character.templates) + const canCreateAction = Boolean( + templateImages.east || outfit.previewUrl || character.referenceImageUrl, + ) + const quickStartPath = `/quick-start?${new URLSearchParams({ + characterId: character.id, + outfitId: outfit.id, + })}` + + async function openWorkflowEditor() { + if (creatingEditor) return + setCreatingEditor(true) + setEntryError(null) + try { + const { run } = await createExistingCharacterActionRun( + { characterId: character.id, outfitId: outfit.id }, + { characterApis, workflowRunApis }, + ) + navigate(`/workflow-editor/${encodeURIComponent(run.id)}`) + } catch (cause) { + setEntryError(cause instanceof Error ? cause.message : '无法创建动作工作流') + setCreatingEditor(false) + } + } return (
-

- 动作与帧 -

+
+

+ 动作与帧 +

+
+ 点击卡片展开完整帧 + +
+
+ + {entryOpen ? ( +
{ + if (event.target === event.currentTarget && !creatingEditor) setEntryOpen(false) + }} + > +
+
+
+

+ 选择动作创建方式 +

+

+ 两种方式都会复用“{outfit.name}”的角色母版,直接开始制作新动作。 +

+
+ +
+ +
+ +
+ {entryError ? ( +

+ {entryError} +

+ ) : null} +
+
+ ) : null} {outfit.actions.length === 0 ? (
diff --git a/frontend/src/pages/quick-start/service.test.ts b/frontend/src/pages/quick-start/service.test.ts index 9fc6e505..0bb53602 100644 --- a/frontend/src/pages/quick-start/service.test.ts +++ b/frontend/src/pages/quick-start/service.test.ts @@ -21,11 +21,12 @@ import { registerApiAccessTokenProvider } from '@/shared/api' function createWorkflowRunApis(initialRuns: readonly WorkflowRun[] = []): WorkflowRunApis { let version = 0 + let runSequence = initialRuns.length const runs = new Map(initialRuns.map((run) => [run.id, structuredClone(run)])) return { async create(input) { const run: WorkflowRun = { - id: 'run-1', + id: `run-${++runSequence}`, projectId: input.projectId, version: ++version, storageStatus: 'active', @@ -938,9 +939,10 @@ describe('createQuickStartService', () => { await service.startAction(target, '站立挥手') const finalSession = await service.startAction(target, '跑步攻击') const finalRun = finalSession.getWorkflow() - expect(secondSession.runId).toBe(firstSession.runId) - expect(finalSession.runId).toBe(firstSession.runId) - expect(finalRun.nodes.filter((node) => node.type === 'action-first-frame')).toHaveLength(6) + expect(secondSession.runId).not.toBe(firstSession.runId) + expect(finalSession.runId).not.toBe(firstSession.runId) + expect(finalSession.runId).not.toBe(secondSession.runId) + expect(finalRun.nodes.filter((node) => node.type === 'action-first-frame')).toHaveLength(1) expect(generationApis.create).toHaveBeenCalledTimes(6) expect(generationApis.create).toHaveBeenNthCalledWith( 1, @@ -2001,6 +2003,12 @@ describe('createQuickStartService', () => { mirrorX: false, imageUrl: 'existing-north.png', }, + { + direction: 'south', + sourceDirection: null, + mirrorX: false, + imageUrl: 'existing-south.png', + }, ], }) const generationApis = pendingGenerationApis() @@ -2014,7 +2022,7 @@ describe('createQuickStartService', () => { update: vi.fn(), remove: vi.fn(), } as unknown as CharacterApis, - projectApis: projectReader(), + projectApis: projectReader(undefined, 'four-way'), prepareProject: vi.fn(), }) @@ -2027,18 +2035,28 @@ describe('createQuickStartService', () => { type: 'character-setup', input: { characterId: character.id, - prompt: '', - referenceMedia: ['existing.png', 'existing-north.png'], + prompt: '老角色', + referenceMedia: ['existing.png', 'existing-north.png', 'existing-south.png'], }, }) expect(run.nodes[1]).toMatchObject({ type: 'character-template', selectedImageUrl: 'existing.png', - selectedImages: { east: 'existing.png', north: 'existing-north.png' }, + selectedImages: { + east: 'existing.png', + north: 'existing-north.png', + south: 'existing-south.png', + }, }) expect(run.nodes.find((node) => node.type === 'action-first-frame')).toMatchObject({ input: { name: '待机', type: 'idle', prompt: null }, }) + expect(generationApis.create).toHaveBeenCalledTimes(3) + expect(vi.mocked(generationApis.create).mock.calls.map(([input]) => input.direction)).toEqual([ + 'east', + 'north', + 'south', + ]) }) it('keeps a custom action display name bounded while preserving its full prompt', async () => { diff --git a/frontend/src/pages/quick-start/service.ts b/frontend/src/pages/quick-start/service.ts index 5b46782b..56470761 100644 --- a/frontend/src/pages/quick-start/service.ts +++ b/frontend/src/pages/quick-start/service.ts @@ -4,7 +4,6 @@ import { createMediaApis, projectApis, workflowRunApis, - characterTemplateImages, characterTemplatesFromImages, getDirectionProfile, type Action, @@ -25,6 +24,7 @@ import { getApiAccessToken, recoverApiUnauthorized, resolveApiBaseUrl } from '@/ import { createEventStreamSubscriber } from '@/shared/api/stream' import { createAutoPrepareProject, + createExistingCharacterActionRun, createWorkflowController, type PrepareQuickStartProject, type WorkflowController, @@ -388,39 +388,6 @@ export function createQuickStartService({ ] } - function existingCharacterNodes( - character: Character, - templateUrl: string, - prompt: string, - ): WorkflowNode[] { - const selectedImages = characterTemplateImages(character.templates) - if (!selectedImages.east) selectedImages.east = templateUrl - const referenceMedia = [...new Set(Object.values(selectedImages))] as MediaReference[] - return [ - { - id: 'character-setup', - type: 'character-setup', - status: 'passed', - phase: 'completed', - dependsOnNodeIds: [], - generations: [], - error: null, - input: { characterId: character.id, prompt, referenceMedia }, - }, - { - id: 'character-template', - type: 'character-template', - status: 'passed', - phase: 'completed', - dependsOnNodeIds: ['character-setup'], - generations: [], - error: null, - selectedImageUrl: templateUrl, - selectedImages, - }, - ] - } - async function createRun( projectId: string, nodes: WorkflowNode[], @@ -963,40 +930,12 @@ export function createQuickStartService({ actionDescription: string, ) { if (!characterApis) throw new Error('角色服务尚未配置,不能增加动作') - const character = await characterApis.get(target.characterId) - const outfit = character.outfits.find((item) => item.id === target.outfitId) - if (!outfit) { - throw new Error('当前造型还没有可用的角色母版,请先完成定妆再生成动作') - } - const sourceImages = characterTemplateImages(character.templates) - const templateUrl = sourceImages.east ?? outfit.previewUrl ?? character.referenceImageUrl - if (!templateUrl) { - throw new Error('当前造型还没有可用的角色母版,请先完成定妆再生成动作') - } - - if (!workflowRunApis.listByProject) { - throw new Error('工作流列表服务尚未配置,不能为现有角色增加动作') - } - const listed = await workflowRunApis.listByProject(character.projectId, { - page: 1, - pageSize: 100, + const { run, character, outfit } = await createExistingCharacterActionRun(target, { + characterApis, + workflowRunApis, }) - const existing = listed.items.find((run) => setupNode(run).input.characterId === character.id) - const project = await projectApis.get(character.projectId) - projectSpriteSizes.set(project.id, project.spriteSize) - projectDirectionalMovements.set(project.id, project.directionalMovement) - const controller = existing - ? createController(existing, project.directionalMovement) - : await createRun( - character.projectId, - existingCharacterNodes( - character, - templateUrl, - character.description ?? actionDescription, - ), - project.directionalMovement, - ) - const spriteSize = project.spriteSize + const spriteSize = await resolveProjectSpriteSize(character.projectId) + const controller = createController(run) await prepareAction(controller, outfit.id, actionDescription, spriteSize) return createSession(controller, spriteSize) } diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index d1a297c0..98d5cca8 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -131,6 +131,100 @@ describe('createRealWorkflowEditorSession', () => { ).rejects.toThrow('WorkflowRun 42 关联了多个角色') }) + it('新增动作 Run 按节点中的 characterId 恢复原角色', async () => { + const character = { ...characterFixture(), workflowRunId: 'original-run' } + const workflow = { + ...workflowFixture(), + id: 'new-action-run', + nodes: [ + { + ...workflowFixture().nodes[0]!, + status: 'passed' as const, + phase: 'completed' as const, + input: { + characterId: character.id, + prompt: '冒险家', + referenceMedia: ['https://assets.windup.test/master.png' as MediaReference], + }, + }, + ], + } + const getCharacter = vi.fn().mockResolvedValue(character) + const listByProject = vi.fn() + + const session = await createRealWorkflowEditorSession('new-action-run', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + mediaApis: { upload: vi.fn() }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + render3d: stubRender3DApis(), + characterApis: { + get: getCharacter, + listByProject, + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + onAsyncError: vi.fn(), + }) + + expect(getCharacter).toHaveBeenCalledWith(character.id) + expect(listByProject).not.toHaveBeenCalled() + expect(session.character).toEqual(character) + }) + + it('拒绝把节点显式绑定的角色跨项目载入编辑器', async () => { + const workflow = { + ...workflowFixture(), + nodes: [ + { + ...workflowFixture().nodes[0]!, + input: { + characterId: '9', + prompt: '冒险家', + referenceMedia: [], + }, + }, + ], + } + + await expect( + createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + mediaApis: { upload: vi.fn() }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + render3d: stubRender3DApis(), + characterApis: { + get: vi.fn().mockResolvedValue({ ...characterFixture(), projectId: 'other-project' }), + listByProject: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }, + onAsyncError: vi.fn(), + }), + ).rejects.toThrow('角色 9 不属于 WorkflowRun 所在项目') + }) + it('把 Controller 异步错误同时交给装配层和页面订阅者', async () => { const onAsyncError = vi.fn() const workflow = workflowFixture() diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts index e43f22b8..2eb8ffe6 100644 --- a/frontend/src/pages/workflow-editor/runtime.ts +++ b/frontend/src/pages/workflow-editor/runtime.ts @@ -8,6 +8,7 @@ import type { MediaReference, Project, ProjectApis, + CharacterSetupWorkflowNode, CharacterTemplateWorkflowNode, Render3DApis, ReviewWorkflowNode, @@ -70,9 +71,16 @@ export async function createRealWorkflowEditorSession( dependencies: RealWorkflowEditorDependencies, ): Promise { const workflow = await dependencies.workflowRunApis.get(runId) + const setup = workflow.nodes.find( + (node): node is CharacterSetupWorkflowNode => + node.type === 'character-setup' && !node.deletedAt, + ) + const explicitCharacterId = setup?.input.characterId const [project, loadedCharacter] = await Promise.all([ dependencies.projectApis.get(workflow.projectId), - loadWorkflowCharacter(dependencies.characterApis, workflow.projectId, workflow.id), + explicitCharacterId + ? loadExplicitCharacter(dependencies.characterApis, workflow.projectId, explicitCharacterId) + : loadWorkflowCharacter(dependencies.characterApis, workflow.projectId, workflow.id), ]) let currentCharacter = loadedCharacter const errorListeners = new Set<(error: Error) => void>() @@ -252,6 +260,18 @@ export function loadDefaultActionPresets(signal?: AbortSignal): Promise, + projectId: Project['id'], + characterId: Character['id'], +): Promise { + const character = await apis.get(characterId) + if (character.projectId !== projectId) { + throw new Error(`角色 ${characterId} 不属于 WorkflowRun 所在项目`) + } + return character +} + async function loadWorkflowCharacter( apis: Pick, projectId: Project['id'],