Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions frontend/src/app/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('AppRoutes authentication boundary', () => {
'/?account=login&returnTo=%2Fquick-start%3Fdraft%3D1%23setup',
),
)
expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull()
expect(screen.queryByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeNull()
})

it('redirects a guest from the PlayTest entry and preserves that return path', async () => {
Expand Down Expand Up @@ -110,13 +110,13 @@ describe('AppRoutes authentication boundary', () => {
</AuthSessionProvider>,
)

expect(screen.queryByRole('heading', { name: '快速开始' })).toBeNull()
expect(screen.queryByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeNull()
expect(screen.getByTestId('location').textContent).toBe('/quick-start')

const restoredTokens = await baseApis.refresh('stored-refresh-token')
await act(async () => resolveRefresh(restoredTokens))

expect(await screen.findByRole('heading', { name: '快速开始' })).toBeTruthy()
expect(await screen.findByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeTruthy()
})

it('renders protected product pages for an authenticated session', async () => {
Expand All @@ -128,7 +128,7 @@ describe('AppRoutes authentication boundary', () => {
</AuthenticatedAuthSession>,
)

expect(await screen.findByRole('heading', { name: '快速开始' })).toBeTruthy()
expect(await screen.findByRole('heading', { name: /开始一条可追踪的制作流程/ })).toBeTruthy()
})

it('tells the user when restoring the session fails instead of becoming a silent guest', async () => {
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/entities/workflow-run/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,53 @@ describe('workflowRunApis', () => {
kind: 'invalid-response',
})
})

it('lists project runs and preserves the character binding', async () => {
let requestUrl = ''
const setupNode = nodes[0]
if (setupNode?.type !== 'character-setup') throw new Error('test fixture is invalid')
const listedRun = {
...workflowRunDto,
nodes: [
{
...setupNode,
input: { ...setupNode.input, characterId: 'character-7' },
},
],
}
const apis = await loadWorkflowRunApis(async (input) => {
requestUrl = String(input)
return new Response(
JSON.stringify({
code: 200,
message: 'success',
data: [listedRun],
total: 1,
page: 2,
page_size: 10,
}),
{ headers: { 'content-type': 'application/json' } },
)
})

await expect(apis.listByProject('42', { page: 2, pageSize: 10 })).resolves.toMatchObject({
items: [
{
id: '17',
nodes: [
{
type: 'character-setup',
input: { characterId: 'character-7' },
},
],
},
],
total: 1,
page: 2,
pageSize: 10,
})
expect(requestUrl).toBe(
'https://api.windup.test/workflow-runs?project_id=42&page=2&page_size=10',
)
})
})
15 changes: 13 additions & 2 deletions frontend/src/entities/workflow-run/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ function hasValidCharacterInput(value: unknown): boolean {
(typeof value.name === 'string' &&
value.name.trim().length > 0 &&
value.name.length <= 20)) &&
(value.characterId === undefined || isNullableString(value.characterId)) &&
typeof value.prompt === 'string' &&
Array.isArray(value.referenceMedia) &&
value.referenceMedia.every((item) => typeof item === 'string')
Expand Down Expand Up @@ -272,8 +273,8 @@ function getApiClient() {
return createApiClient({ getAccessToken: getApiAccessToken })
}

/** 精确对应后端已公开的 CRUD;不声明尚未提供的列表或按 Character 查询。 */
export const workflowRunApis: WorkflowRunApis = {
/** 精确对应后端已公开的 CRUD 与项目内分页列表;不声明尚未提供的按 Character 查询。 */
export const workflowRunApis: WorkflowRunApis & Required<Pick<WorkflowRunApis, 'listByProject'>> = {
async create(input) {
return mapWorkflowRun(
await getApiClient().request<WorkflowRunDto>('/workflow-runs', {
Expand All @@ -282,6 +283,16 @@ export const workflowRunApis: WorkflowRunApis = {
}),
)
},
async listByProject(projectId, query = {}) {
const result = await getApiClient().requestList<WorkflowRunDto>('/workflow-runs', {
query: {
project_id: toBackendId(projectId, 'projectId'),
page: query.page,
page_size: query.pageSize,
},
})
return { ...result, items: result.items.map(mapWorkflowRun) }
},
async get(id) {
return mapWorkflowRun(
await getApiClient().request<WorkflowRunDto>(`/workflow-runs/${encodeURIComponent(id)}`),
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/entities/workflow-run/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ActionType } from '../character'
import type { Generation } from '../generation'
import type { MediaReference } from '../media'
import type { Paged, PageQuery } from '@/shared/pagination'
import {
WORKFLOW_GENERATION_ROLES,
WORKFLOW_NODE_PHASES,
Expand Down Expand Up @@ -46,6 +47,11 @@ interface WorkflowNodeBase {
export interface WorkflowCharacterInput {
/** 用户填写或后端提取的最终角色名称;旧数据可以没有该字段。 */
name?: string | null
/**
* 当前节点图所属的 Character。后端只原样持久化 nodes,因此前端用它在项目列表中定位角色的唯一 Run。
* 旧 Run 可能没有该字段;读取方必须兼容未绑定状态。
*/
characterId?: string | null
prompt: string
referenceMedia: readonly MediaReference[]
}
Expand Down Expand Up @@ -130,6 +136,8 @@ export interface CreateWorkflowRunInput {

export interface WorkflowRunApis {
create(input: CreateWorkflowRunInput): Promise<WorkflowRun>
/** 后端只返回未软删除的运行记录。 */
listByProject?(projectId: string, query?: PageQuery): Promise<Paged<WorkflowRun>>
get(id: WorkflowRun['id']): Promise<WorkflowRun>
update(run: WorkflowRun): Promise<WorkflowRun>
remove(id: WorkflowRun['id']): Promise<void>
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/features/workflow-controller/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,52 @@ async function flushAsyncWork() {
}

describe('WorkflowController', () => {
it('绑定角色后拒绝把同一条 WorkflowRun 改绑到另一角色', async () => {
const { controller } = createController()

await controller.bindCharacter('setup-1', 'character-1')

expect(controller.getWorkflow().nodes[0]).toMatchObject({
type: 'character-setup',
input: { characterId: 'character-1' },
})
await expect(controller.bindCharacter('setup-1', 'character-2')).rejects.toThrow(
'WorkflowRun 已绑定到另一角色,不能改绑',
)
})

it('只在角色设定节点仍处于配置阶段时更新提示词和参考媒体', async () => {
const { controller } = createController()

await controller.updateCharacterSetup('setup-1', {
prompt: '披着红色斗篷的像素骑士',
referenceMedia: ['https://img/reference.png' as never],
})

expect(controller.getWorkflow().nodes[0]).toMatchObject({
input: {
prompt: '披着红色斗篷的像素骑士',
referenceMedia: ['https://img/reference.png'],
},
})
})

it('接受上传母版时完成角色设定和母版节点', async () => {
const { controller } = createController()

await controller.acceptUploadedCharacterTemplate('setup-1', 'https://img/uploaded-template.png')

expect(controller.getWorkflow().nodes).toMatchObject([
{ type: 'character-setup', status: 'passed', phase: 'completed' },
{
type: 'character-template',
status: 'passed',
phase: 'completed',
selectedImageUrl: 'https://img/uploaded-template.png',
},
])
})

it('页面通过订阅接收命令保存和 SSE 写回后的同一份 WorkflowRun', async () => {
const { controller, generation } = createController()
let renderedWorkflow = controller.getWorkflow()
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/features/workflow-controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ export interface WorkflowController {
nodeId: CharacterSetupWorkflowNode['id'],
options: GenerateCharacterTemplateOptions,
): Promise<void>
/** 将已创建的 Character 绑定到入口节点;一条 Run 不允许改绑到另一角色。 */
bindCharacter(nodeId: CharacterSetupWorkflowNode['id'], characterId: string): Promise<void>
/** 仅在入口节点尚未提交时修改角色描述和参考媒体。 */
updateCharacterSetup(
nodeId: CharacterSetupWorkflowNode['id'],
input: Pick<WorkflowCharacterInput, 'prompt' | 'referenceMedia'>,
): Promise<void>
/** 使用用户上传的角色母版,显式跳过角色候选图生成。 */
acceptUploadedCharacterTemplate(
nodeId: CharacterSetupWorkflowNode['id'],
selectedImageUrl: string,
): Promise<void>
confirmCharacterTemplate(
nodeId: CharacterTemplateWorkflowNode['id'],
selectedImageUrl: string,
Expand Down Expand Up @@ -397,6 +409,83 @@ export function createWorkflowController({
)
}

function bindCharacter(nodeId: CharacterSetupWorkflowNode['id'], characterId: string) {
ensureRunning()
const normalizedCharacterId = nonEmpty(characterId, 'characterId')
return persist((run) =>
updateNode(run, nodeId, (node) => {
if (node.type !== 'character-setup') throw new Error('目标节点不是角色设定')
if (node.input.characterId && node.input.characterId !== normalizedCharacterId) {
throw new Error('WorkflowRun 已绑定到另一角色,不能改绑')
}
return replaceNode(run, {
...node,
input: { ...node.input, characterId: normalizedCharacterId },
})
}),
)
}

function updateCharacterSetup(
nodeId: CharacterSetupWorkflowNode['id'],
input: Pick<WorkflowCharacterInput, 'prompt' | 'referenceMedia'>,
) {
ensureRunning()
const prompt = nonEmpty(input.prompt, 'prompt')
return persist((run) =>
updateNode(run, nodeId, (node) => {
if (node.type !== 'character-setup') throw new Error('目标节点不是角色设定')
if (node.status !== 'active' || node.phase !== 'configuring') {
throw new Error('角色设定节点当前不能修改')
}
return replaceNode(run, {
...node,
input: {
...node.input,
prompt,
referenceMedia: [...input.referenceMedia],
},
})
}),
)
}

function acceptUploadedCharacterTemplate(
nodeId: CharacterSetupWorkflowNode['id'],
selectedImageUrl: string,
) {
ensureRunning()
const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl')
return persist((run) => {
const setupNode = findNode(run, nodeId)
if (setupNode.type !== 'character-setup') throw new Error('目标节点不是角色设定')
if (setupNode.status !== 'active' || setupNode.phase !== 'configuring') {
throw new Error('角色设定节点当前不能使用上传母版')
}
const templateNode = findSingleDependentNode(run, setupNode.id, 'character-template')
if (templateNode.status !== 'locked' || templateNode.phase !== 'ready') {
throw new Error('角色母版节点当前不能使用上传图片')
}
return unlockReadyNodes({
...run,
nodes: run.nodes.map((node) => {
if (node.id === setupNode.id) {
return { ...setupNode, status: 'passed', phase: 'completed', error: null }
}
if (node.id === templateNode.id) {
return {
...templateNode,
selectedImageUrl: imageUrl,
status: 'passed',
phase: 'completed',
}
}
return node
}),
})
})
}

function generateFirstFrame(
nodeId: ActionFirstFrameWorkflowNode['id'],
options: GenerateActionOptions,
Expand Down Expand Up @@ -871,6 +960,9 @@ export function createWorkflowController({
setCharacterName: asCommand(setCharacterName),
addAction: asCommand(addAction),
generateCharacterTemplate: asCommand(generateCharacterTemplate),
bindCharacter: asCommand(bindCharacter),
updateCharacterSetup: asCommand(updateCharacterSetup),
acceptUploadedCharacterTemplate: asCommand(acceptUploadedCharacterTemplate),
confirmCharacterTemplate: asCommand(confirmCharacterTemplate),
generateFirstFrame: asCommand(generateFirstFrame),
confirmFirstFrame: asCommand(confirmFirstFrame),
Expand Down
Loading
Loading