This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
putCharacterRoleAssetWorkflow,
resolveCharacterRoleAssetWorkflow,
} from './characterAssetWorkflowPersistence';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('角色资产工坊 workflow client', () => {
it('通过后端 workflow 接口解析默认 prompt 和缓存合并结果', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () =>
JSON.stringify({
ok: true,
cache: null,
workflow: {
defaultPromptBundle: {
visualPromptText: '默认视觉',
animationPromptText: '默认动作',
scenePromptText: '默认场景',
},
visualPromptText: '默认视觉',
animationPromptText: '默认动作',
animationPromptTextByKey: { run: '默认动作' },
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'run',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await resolveCharacterRoleAssetWorkflow({
characterId: 'role 01',
cacheScopeId: 'world-01',
role: {
id: 'role 01',
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
},
});
expect(result.workflow.visualPromptText).toBe('默认视觉');
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/asset-studio/role/role%2001/workflow',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
cacheScopeId: 'world-01',
role: {
id: 'role 01',
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
},
}),
}),
);
});
it('使用 PUT 保存用户当前工坊草稿缓存', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () =>
JSON.stringify({
ok: true,
cache: { characterId: 'role-01' },
saveMessage: '已保存',
}),
});
vi.stubGlobal('fetch', fetchMock);
await putCharacterRoleAssetWorkflow({
characterId: 'role-01',
cacheScopeId: 'world-01',
visualPromptText: '视觉草稿',
animationPromptText: '动作草稿',
animationPromptTextByKey: { run: '奔跑草稿' },
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'run',
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/asset-studio/role/role-01/workflow',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
characterId: 'role-01',
cacheScopeId: 'world-01',
visualPromptText: '视觉草稿',
animationPromptText: '动作草稿',
animationPromptTextByKey: { run: '奔跑草稿' },
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'run',
}),
}),
);
});
});

View File

@@ -2,7 +2,10 @@ import {
ASSET_API_PATHS,
postApiJson,
} from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import {
fetchJson,
parseApiErrorMessage,
} from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate;
@@ -21,6 +24,8 @@ export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
ASSET_API_PATHS.characterAnimationImportVideo;
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
ASSET_API_PATHS.characterAnimationTemplates;
export const ROLE_ASSET_WORKFLOW_API_PATH =
'/api/runtime/custom-world/asset-studio/role';
export type CharacterVisualSourceMode =
| 'text-to-image'
@@ -61,6 +66,48 @@ export type CharacterAssetWorkflowCache = {
updatedAt?: string;
};
export type CharacterAssetRolePromptInput = {
id: string;
name?: string;
title?: string;
role?: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
};
export type CharacterRolePromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
};
export type CharacterRoleAssetWorkflow = {
role: CharacterAssetRolePromptInput;
defaultPromptBundle: CharacterRolePromptBundle;
visualPromptText: string;
animationPromptText: string;
animationPromptTextByKey: Record<string, string>;
visualDrafts: CharacterVisualDraft[];
selectedVisualDraftId: string;
selectedAnimation: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
updatedAt?: string;
};
export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
@@ -185,6 +232,60 @@ export async function saveCharacterWorkflowCache(
);
}
export async function resolveCharacterRoleAssetWorkflow(payload: {
characterId: string;
cacheScopeId?: string;
role: CharacterAssetRolePromptInput;
}) {
const { characterId, cacheScopeId, role } = payload;
return postApiJson<{
ok: true;
cache: CharacterAssetWorkflowCache | null;
workflow: CharacterRoleAssetWorkflow;
}>(
`${ROLE_ASSET_WORKFLOW_API_PATH}/${encodeURIComponent(characterId)}/workflow`,
{
cacheScopeId,
role,
},
'读取角色资产工坊工作流失败',
);
}
export async function putCharacterRoleAssetWorkflow(
payload: CharacterAssetWorkflowCache,
) {
const url = `${ROLE_ASSET_WORKFLOW_API_PATH}/${encodeURIComponent(payload.characterId)}/workflow`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '保存角色资产工坊缓存失败'),
);
}
return responseText
? (JSON.parse(responseText) as {
ok: true;
cache: CharacterAssetWorkflowCache;
saveMessage: string;
})
: ({
ok: true,
cache: payload,
saveMessage: '',
} as {
ok: true;
cache: CharacterAssetWorkflowCache;
saveMessage: string;
});
}
export async function fetchCharacterVisualJobStatus(taskId: string) {
return fetchJson<CharacterAssetJobStatus>(
`${CHARACTER_VISUAL_JOB_API_PATH}/${encodeURIComponent(taskId)}`,

View File

@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('uses model-generated role descriptions directly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
visualDescription:
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
actionDescription:
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
sceneVisualDescription:
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
description: '熟悉裂潮边路的灰炬向导。',
});
expect(result.visualPromptText).toBe(
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
);
expect(result.animationPromptText).toBe(
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
);
expect(result.scenePromptText).toBe(
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
);
});
it('falls back to existing entity descriptions without assembling new rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
role: '场景角色',
description: '总在潮雾港高处盯着来往船影的守望者。',
personality: '寡言、敏锐、先看人再开口。',
combatStyle: '长枪封线后借高差压制。',
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -1 +0,0 @@
export * from '../../prompts/customWorldRolePromptDefaults';