合并主分支

解决简单冲突
This commit is contained in:
2026-08-06 18:54:32 +08:00
55 changed files with 11232 additions and 270 deletions
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import {
completeEditorBackgroundMusicPrompt,
createEditorAsset,
createEditorAssetFolder,
createEditorProject,
@@ -13,6 +15,7 @@ import {
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
@@ -32,6 +35,7 @@ import {
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
simplifyEditorBackgroundMusicPrompt,
snapEditorImageToPixelArt,
splitEditorIconSpritesheet,
submitEditorAssetShowcase,
@@ -46,6 +50,10 @@ const editorRetryOptionsExpectation = expect.objectContaining({
maxRetries: 0,
retryUnsafeMethods: false,
});
// 助手请求只带有界超时:额外的 transport retry 会把一次业务语义轮放大成多次上游调用。
const promptAssistRequestOptions = {
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
};
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
@@ -1983,13 +1991,21 @@ describe('editorProjectClient', () => {
});
it('generates editor background music through the backend BFF', async () => {
const representativeComplexPromptCase =
backgroundMusicPromptCanonicalizationCases.find(
({ name }) => name === 'representative-complex',
);
if (!representativeComplexPromptCase) {
throw new Error('missing representative-complex prompt fixture');
}
const canonicalPrompt = representativeComplexPromptCase.prompt;
requestJsonMock.mockResolvedValueOnce({
audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3',
width: 420,
height: 120,
sourceType: 'generated',
prompt: '森林冒险背景音乐',
actualPrompt: '森林冒险背景音乐',
prompt: canonicalPrompt,
actualPrompt: canonicalPrompt,
model: 'chirp-v4',
provider: 'VectorEngine',
taskId: 'music-task-1',
@@ -1997,7 +2013,7 @@ describe('editorProjectClient', () => {
});
const result = await generateEditorBackgroundMusic({
gptDescriptionPrompt: '森林冒险背景音乐',
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
assetFolderId: 'project',
assetLabel: '游戏背景音乐 1',
@@ -2010,20 +2026,196 @@ describe('editorProjectClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gptDescriptionPrompt: '森林冒险背景音乐',
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
assetFolderId: 'project',
assetLabel: '游戏背景音乐 1',
}),
}),
'生成游戏背景音乐失败',
expect.objectContaining({
{
timeoutMs: 1_200_000,
retry: editorRetryOptionsExpectation,
}),
},
);
});
it('forwards background music transport failures without configuring retries', async () => {
requestJsonMock.mockRejectedValueOnce(new Error('transport failed'));
await expect(
generateEditorBackgroundMusic({
gptDescriptionPrompt: '森林冒险背景音乐',
makeInstrumental: true,
}),
).rejects.toThrow('transport failed');
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
timeoutMs: 1_200_000,
});
});
it('completes a background music prompt through the authenticated internal BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
prompt: '轻快明亮的森林冒险背景音乐',
charCount: 13,
});
const result = await completeEditorBackgroundMusicPrompt({
currentPrompt: '森林冒险',
targetChars: 180,
maxChars: 200,
} as Parameters<typeof completeEditorBackgroundMusicPrompt>[0] & {
targetChars: number;
maxChars: number;
});
expect(result).toEqual({
prompt: '轻快明亮的森林冒险背景音乐',
charCount: 13,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/audios/background-music/prompts/completions',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPrompt: '森林冒险',
}),
signal: undefined,
},
'AI 补全背景音乐提示词失败',
promptAssistRequestOptions,
);
expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
});
it('simplifies a background music prompt without forwarding client-controlled limits', async () => {
requestJsonMock.mockResolvedValueOnce({
prompt: '紧张推进的战斗背景音乐',
charCount: 11,
});
const result = await simplifyEditorBackgroundMusicPrompt({
currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10),
targetChars: 170,
maxChars: 200,
model: 'client-must-not-control',
} as Parameters<typeof simplifyEditorBackgroundMusicPrompt>[0] & {
targetChars: number;
maxChars: number;
model: string;
});
expect(result).toEqual({
prompt: '紧张推进的战斗背景音乐',
charCount: 11,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/audios/background-music/prompts/simplifications',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10),
}),
signal: undefined,
},
'简化背景音乐提示词失败',
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions);
});
it.each([
[
'completion',
completeEditorBackgroundMusicPrompt,
'/api/editor/audios/background-music/prompts/completions',
'AI 补全背景音乐提示词失败',
],
[
'simplification',
simplifyEditorBackgroundMusicPrompt,
'/api/editor/audios/background-music/prompts/simplifications',
'简化背景音乐提示词失败',
],
] as const)(
'forwards the AbortSignal for background music prompt %s',
async (_label, requestPromptAssist, path, fallbackMessage) => {
const controller = new AbortController();
const abortReason = new Error('prompt assist cancelled');
requestJsonMock.mockImplementationOnce(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
);
const request = requestPromptAssist(
{ currentPrompt: '森林冒险' },
{ signal: controller.signal },
);
controller.abort(abortReason);
await expect(request).rejects.toBe(abortReason);
expect(requestJsonMock).toHaveBeenCalledWith(
path,
expect.objectContaining({
signal: controller.signal,
body: JSON.stringify({ currentPrompt: '森林冒险' }),
}),
fallbackMessage,
promptAssistRequestOptions,
);
},
);
it.each([
[
'completion',
completeEditorBackgroundMusicPrompt,
'AI 补全背景音乐提示词失败',
],
[
'simplification',
simplifyEditorBackgroundMusicPrompt,
'简化背景音乐提示词失败',
],
] as const)(
'preserves the v1 API error from background music prompt %s',
async (_label, requestPromptAssist, fallbackMessage) => {
const apiError = Object.assign(new Error('登录状态已失效'), {
name: 'ApiClientError',
status: 401,
code: 'UNAUTHORIZED',
});
requestJsonMock.mockRejectedValueOnce(apiError);
await expect(
requestPromptAssist({ currentPrompt: '森林冒险' }),
).rejects.toBe(apiError);
expect(requestJsonMock).toHaveBeenCalledWith(
expect.stringMatching(
/^\/api\/editor\/audios\/background-music\/prompts\//,
),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPrompt: '森林冒险' }),
}),
fallbackMessage,
promptAssistRequestOptions,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
},
);
it('edits editor images through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,edited',
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setStoredAccessToken } from '../apiClient';
import { generateEditorBackgroundMusic } from './editorProjectClient';
function createLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
};
}
describe('editorProjectClient background music transport', () => {
const fetchMock = vi.fn();
const requestBody = {
gptDescriptionPrompt: '森林冒险背景音乐',
makeInstrumental: true as const,
projectId: 'editor-project-1',
};
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
dispatchEvent: vi.fn(),
localStorage: createLocalStorageMock(),
});
setStoredAccessToken('background-music-test-token', { emit: false });
});
afterEach(() => {
vi.unstubAllGlobals();
});
function expectSingleBackgroundMusicPost() {
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/editor/audios/background-music/generations');
expect(init.method).toBe('POST');
expect(JSON.parse(String(init.body))).toEqual(requestBody);
}
it.each([429, 503])(
'does not retry the formal POST after an HTTP %i response',
async (status) => {
fetchMock.mockResolvedValueOnce(new Response('', { status }));
await expect(
generateEditorBackgroundMusic(requestBody),
).rejects.toMatchObject({ status });
expectSingleBackgroundMusicPost();
},
);
it('does not retry the formal POST after a transport error', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(
generateEditorBackgroundMusic(requestBody),
).rejects.toBeInstanceOf(TypeError);
expectSingleBackgroundMusicPost();
});
});
@@ -1,3 +1,7 @@
import type {
BackgroundMusicPromptAssistRequest,
BackgroundMusicPromptAssistResponse,
} from '../../../packages/shared/src/contracts/editorAudio';
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { requestJson } from '../apiClient';
import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
@@ -34,6 +38,15 @@ const EDITOR_SOUND_EFFECT_GENERATION_API =
'/api/editor/audios/sound-effects/generations';
const EDITOR_BACKGROUND_MUSIC_GENERATION_API =
'/api/editor/audios/background-music/generations';
const EDITOR_BACKGROUND_MUSIC_PROMPT_COMPLETION_API =
'/api/editor/audios/background-music/prompts/completions';
const EDITOR_BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_API =
'/api/editor/audios/background-music/prompts/simplifications';
/**
* 覆盖简化两个业务语义轮加单轮 transport retry 的服务端预算,同时为浏览器到 BFF 的
* 悬挂连接提供有界退出;超时后走既有失败路径恢复 idle,不写回候选。
*/
export const EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000;
const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing';
const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
const EDITOR_IMAGE_REFERENCE_LIMIT = 5;
@@ -620,6 +633,10 @@ export type EditorBackgroundMusicGenerationInput = {
assetLabel?: string | null;
};
export type EditorBackgroundMusicPromptAssistOptions = {
signal?: AbortSignal;
};
export type EditorAudioGenerationResult = {
audioSrc: string;
objectKey?: string | null;
@@ -1487,6 +1504,51 @@ export async function generateEditorSoundEffect(
);
}
function requestEditorBackgroundMusicPromptAssist(
path: string,
input: BackgroundMusicPromptAssistRequest,
fallbackMessage: string,
options: EditorBackgroundMusicPromptAssistOptions,
) {
return requestJson<BackgroundMusicPromptAssistResponse>(
path,
{
...jsonRequest('POST', {
currentPrompt: input.currentPrompt,
}),
signal: options.signal,
},
fallbackMessage,
{
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
},
);
}
export function completeEditorBackgroundMusicPrompt(
input: BackgroundMusicPromptAssistRequest,
options: EditorBackgroundMusicPromptAssistOptions = {},
) {
return requestEditorBackgroundMusicPromptAssist(
EDITOR_BACKGROUND_MUSIC_PROMPT_COMPLETION_API,
input,
'AI 补全背景音乐提示词失败',
options,
);
}
export function simplifyEditorBackgroundMusicPrompt(
input: BackgroundMusicPromptAssistRequest,
options: EditorBackgroundMusicPromptAssistOptions = {},
) {
return requestEditorBackgroundMusicPromptAssist(
EDITOR_BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_API,
input,
'简化背景音乐提示词失败',
options,
);
}
export async function generateEditorBackgroundMusic(
input: EditorBackgroundMusicGenerationInput,
) {
@@ -1508,7 +1570,6 @@ export async function generateEditorBackgroundMusic(
'生成游戏背景音乐失败',
{
timeoutMs: 1_200_000,
retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS,
},
);
}