前端:增加BGM提示词助手状态与客户端

增加补全与简化内部客户端及共享DTO
实现对话框级状态、取消、迟到响应保护和交换式撤销
接入画布生成工作流并将submitting设为排他锁
补齐同步读取、项目切换和字符边界回归测试
This commit is contained in:
2026-08-04 12:36:08 +00:00
parent b563c82c7b
commit 825b0ccf6c
16 changed files with 2723 additions and 3 deletions
@@ -0,0 +1,8 @@
export type BackgroundMusicPromptAssistRequest = {
currentPrompt: string;
};
export type BackgroundMusicPromptAssistResponse = {
prompt: string;
charCount: number;
};
+1
View File
@@ -2,6 +2,7 @@ export type * from './barkBattle';
export type * from './creationAudio';
export type * from './creativeAgent';
export * from './editorAgent';
export type * from './editorAudio';
export * from './gameCreationApp';
export * from './hostBridge';
export type * from './hyper3d';
+1
View File
@@ -5,6 +5,7 @@ export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export type * from './contracts/editorAudio';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,67 @@
export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200;
export const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS =
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS * 10;
export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1;
export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2;
const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u;
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
const BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX =
'background-music-prompt-operation';
export type BackgroundMusicPromptOperationStatus =
| 'completing'
| 'simplifying'
| 'submitting';
export type BackgroundMusicPromptDialogStatus =
| 'idle'
| BackgroundMusicPromptOperationStatus;
export type BackgroundMusicPromptOperation = {
dialogId: string;
operationId: string;
status: BackgroundMusicPromptOperationStatus;
canonicalPrompt: string;
};
export type BackgroundMusicPromptDialogState = {
dialogId: string;
status: BackgroundMusicPromptDialogStatus;
operationId: string | null;
undoPromptSnapshot: string | null;
temporaryPromptSnapshot: string | null;
};
export type BackgroundMusicPromptOperationStartResult = {
started: boolean;
reason: 'started' | 'duplicate' | 'ineligible' | 'locked';
canonicalPrompt: string;
operation: BackgroundMusicPromptOperation | null;
state: BackgroundMusicPromptDialogState;
};
export type BackgroundMusicPromptOperationResolution = {
applied: boolean;
prompt: string | null;
state: BackgroundMusicPromptDialogState;
};
export type BackgroundMusicPromptBoundaryResult = {
applied: boolean;
prompt: string;
state: BackgroundMusicPromptDialogState;
};
type InternalBackgroundMusicPromptDialogState =
BackgroundMusicPromptDialogState & {
activeCanonicalPrompt: string | null;
};
export type BackgroundMusicPromptStateModel = ReturnType<
typeof createBackgroundMusicPromptStateModel
>;
export function canonicalizeBackgroundMusicPrompt(prompt: string) {
return prompt
@@ -43,9 +100,338 @@ export function canCompleteBackgroundMusicPrompt(prompt: string) {
}
export function canSimplifyBackgroundMusicPrompt(prompt: string) {
const codePointCount = countPromptCodePoints(prompt);
return (
countPromptCodePoints(prompt) > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
codePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
codePointCount <= BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
);
}
function createIdleBackgroundMusicPromptDialogState(
dialogId: string,
): InternalBackgroundMusicPromptDialogState {
return {
dialogId,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
}
function toPublicBackgroundMusicPromptDialogState(
state: InternalBackgroundMusicPromptDialogState,
): BackgroundMusicPromptDialogState {
return {
dialogId: state.dialogId,
status: state.status,
operationId: state.operationId,
undoPromptSnapshot: state.undoPromptSnapshot,
temporaryPromptSnapshot: state.temporaryPromptSnapshot,
};
}
function canStartBackgroundMusicPromptOperation(
status: BackgroundMusicPromptOperationStatus,
canonicalPrompt: string,
) {
if (status === 'completing') {
return canCompleteBackgroundMusicPrompt(canonicalPrompt);
}
if (status === 'simplifying') {
return canSimplifyBackgroundMusicPrompt(canonicalPrompt);
}
return canGenerateBackgroundMusicFromPrompt(canonicalPrompt);
}
/**
* Keeps dialog-scoped Prompt workflow state outside React. All operation claims
* happen synchronously, so callers can store one model instance in a ref and
* decide whether to issue a request before their first await.
*/
export function createBackgroundMusicPromptStateModel() {
const dialogStates = new Map<
string,
InternalBackgroundMusicPromptDialogState
>();
let operationSequence = 0;
const readInternalState = (dialogId: string) =>
dialogStates.get(dialogId) ??
createIdleBackgroundMusicPromptDialogState(dialogId);
const saveState = (state: InternalBackgroundMusicPromptDialogState) => {
dialogStates.set(state.dialogId, state);
return toPublicBackgroundMusicPromptDialogState(state);
};
const getDialogState = (dialogId: string): BackgroundMusicPromptDialogState =>
toPublicBackgroundMusicPromptDialogState(readInternalState(dialogId));
const beginOperation = ({
dialogId,
status,
prompt,
}: {
dialogId: string;
status: BackgroundMusicPromptOperationStatus;
prompt: string;
}): BackgroundMusicPromptOperationStartResult => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (currentState.status === status && currentState.operationId !== null) {
return {
started: false,
reason: 'duplicate',
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
if (currentState.status === 'submitting') {
return {
started: false,
reason: 'locked',
canonicalPrompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
if (!canStartBackgroundMusicPromptOperation(status, canonicalPrompt)) {
return {
started: false,
reason: 'ineligible',
canonicalPrompt,
operation: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
operationSequence += 1;
const operation: BackgroundMusicPromptOperation = {
dialogId,
operationId: `${BACKGROUND_MUSIC_PROMPT_OPERATION_ID_PREFIX}-${operationSequence}`,
status,
canonicalPrompt,
};
const isAiOperation = status === 'completing' || status === 'simplifying';
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status,
operationId: operation.operationId,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: isAiOperation ? canonicalPrompt : null,
activeCanonicalPrompt: canonicalPrompt,
};
return {
started: true,
reason: 'started',
canonicalPrompt,
operation,
state: saveState(nextState),
};
};
const isCurrentOperation = (
operation: BackgroundMusicPromptOperation,
state: InternalBackgroundMusicPromptDialogState,
) =>
state.dialogId === operation.dialogId &&
state.operationId === operation.operationId &&
state.status === operation.status;
const resolveAiOperation = (
operation: BackgroundMusicPromptOperation,
prompt: string,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status === 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (!canGenerateBackgroundMusicFromPrompt(canonicalPrompt)) {
const failedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: false,
prompt: null,
state: saveState(failedState),
};
}
const completedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: currentState.temporaryPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(completedState),
};
};
const rejectOperation = (
operation: BackgroundMusicPromptOperation,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (!isCurrentOperation(operation, currentState)) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const isAiOperation = operation.status !== 'submitting';
const failedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: isAiOperation
? null
: currentState.undoPromptSnapshot,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: currentState.activeCanonicalPrompt,
state: saveState(failedState),
};
};
const completeSubmittingOperation = (
operation: BackgroundMusicPromptOperation,
): BackgroundMusicPromptOperationResolution => {
const currentState = readInternalState(operation.dialogId);
if (
operation.status !== 'submitting' ||
!isCurrentOperation(operation, currentState)
) {
return {
applied: false,
prompt: null,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const submittedPrompt =
currentState.activeCanonicalPrompt ?? operation.canonicalPrompt;
const completedState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: submittedPrompt,
state: saveState(completedState),
};
};
const preparePreset = (
dialogId: string,
prompt: string,
): BackgroundMusicPromptBoundaryResult => {
const currentState = readInternalState(dialogId);
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
if (currentState.status === 'submitting') {
return {
applied: false,
prompt: currentState.activeCanonicalPrompt ?? canonicalPrompt,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
activeCanonicalPrompt: null,
};
return {
applied: true,
prompt: canonicalPrompt,
state: saveState(nextState),
};
};
const swapUndoSnapshot = (
dialogId: string,
prompt: string,
): BackgroundMusicPromptBoundaryResult => {
const currentState = readInternalState(dialogId);
if (
currentState.status !== 'idle' ||
currentState.undoPromptSnapshot === null
) {
return {
applied: false,
prompt,
state: toPublicBackgroundMusicPromptDialogState(currentState),
};
}
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
const nextPrompt = currentState.undoPromptSnapshot;
const nextState: InternalBackgroundMusicPromptDialogState = {
...currentState,
undoPromptSnapshot: canonicalPrompt,
};
return {
applied: true,
prompt: nextPrompt,
state: saveState(nextState),
};
};
const closeDialog = (dialogId: string) => {
dialogStates.delete(dialogId);
};
const reset = () => {
dialogStates.clear();
};
return {
getDialogState,
beginOperation,
resolveAiOperation,
rejectOperation,
completeSubmittingOperation,
preparePreset,
swapUndoSnapshot,
closeDialog,
reset,
};
}
@@ -745,6 +745,7 @@ export function ImageCanvasEditorView({
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
@@ -1476,6 +1477,7 @@ export function ImageCanvasEditorView({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -280,4 +280,70 @@ describe('useCanvasGenerationDialogs', () => {
}),
]);
});
it('reads the latest active and archived dialog synchronously by id', () => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
let firstDialogId = '';
let secondDialogId = '';
let activeDialogRead: CanvasGenerationDialogState | undefined;
let archivedDialogRead: CanvasGenerationDialogState | undefined;
act(() => {
firstDialogId = result.current.openCanvasGenerationDialog(
createDialog('audio-background-music', 'first'),
);
activeDialogRead =
result.current.getCanvasGenerationDialogById(firstDialogId);
secondDialogId = result.current.openCanvasGenerationDialog(
createDialog('audio-background-music', 'second'),
);
archivedDialogRead =
result.current.getCanvasGenerationDialogById(firstDialogId);
});
expect(activeDialogRead).toEqual(
expect.objectContaining({
id: firstDialogId,
prompt: 'first',
composerOpen: true,
}),
);
expect(archivedDialogRead).toEqual(
expect.objectContaining({
id: firstDialogId,
prompt: 'first',
composerOpen: false,
}),
);
expect(result.current.getCanvasGenerationDialogById(secondDialogId)).toEqual(
expect.objectContaining({
prompt: 'second',
composerOpen: true,
}),
);
let updatedDialogRead: CanvasGenerationDialogState | undefined;
act(() => {
result.current.updateCanvasGenerationDialogById(
firstDialogId,
(dialog) => ({
...dialog,
prompt: 'updated first',
}),
);
updatedDialogRead =
result.current.getCanvasGenerationDialogById(firstDialogId);
result.current.removeCanvasGenerationDialogById(firstDialogId);
});
expect(updatedDialogRead).toEqual(
expect.objectContaining({
prompt: 'updated first',
composerOpen: false,
}),
);
expect(
result.current.getCanvasGenerationDialogById(firstDialogId),
).toBeUndefined();
});
});
@@ -202,6 +202,19 @@ export function useCanvasGenerationDialogs({
].some((dialog) => dialog.id === dialogId);
}, []);
const getCanvasGenerationDialogById = useCallback((dialogId: string) => {
const currentDialog = generateDialogRef.current;
if (
isCanvasGenerationDialog(currentDialog) &&
currentDialog.id === dialogId
) {
return currentDialog;
}
return inactiveGenerateDialogsRef.current.find(
(dialog) => dialog.id === dialogId,
);
}, []);
const activateCanvasGenerationDialog = useCallback(
(targetDialog: CanvasGenerationDialogState) => {
const currentDialog = generateDialogRef.current;
@@ -323,6 +336,7 @@ export function useCanvasGenerationDialogs({
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -154,6 +154,7 @@ function GenerationSurfaceHarness() {
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog:
dialogs.archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId:
@@ -79,6 +79,9 @@ type ImageCanvasGenerationSurfaceOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
@@ -163,6 +166,7 @@ export function useImageCanvasGenerationSurface({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -200,6 +204,7 @@ export function useImageCanvasGenerationSurface({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -158,6 +158,7 @@ function GenerationWorkflowHarness({
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
@@ -102,6 +102,7 @@ import {
type UiAssetExtractionTool,
updateUiAssetExtractionDraftMark,
} from './ImageCanvasUiAssetExtractionModel';
import { useImageCanvasBackgroundMusicPromptAssist } from './useImageCanvasBackgroundMusicPromptAssist';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
@@ -602,6 +603,9 @@ type GenerationWorkflowOptions = {
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
getCanvasGenerationDialogById: (
dialogId: string,
) => CanvasGenerationDialogState | undefined;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
@@ -644,6 +648,7 @@ export function useImageCanvasGenerationWorkflow({
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
@@ -673,6 +678,13 @@ export function useImageCanvasGenerationWorkflow({
const refreshTaskList = useCallback(() => {
setTaskListRefreshKey((key) => key + 1);
}, []);
const backgroundMusicPromptAssist =
useImageCanvasBackgroundMusicPromptAssist({
canvasGenerationDialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
scopeKey: projectId,
});
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
@@ -2505,6 +2517,7 @@ export function useImageCanvasGenerationWorkflow({
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
backgroundMusicPromptAssist,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
appendUiAssetExtractionReferences,
@@ -2605,6 +2618,7 @@ export function useImageCanvasGenerationWorkflow({
}),
[
effectiveCharacterAnimationPanel,
backgroundMusicPromptAssist,
characterAnimationPrice,
characterAnimationSourceLayer,
clearDeletedLayerGenerationState,
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
completeEditorBackgroundMusicPrompt,
createEditorAsset,
createEditorAssetFolder,
createEditorProject,
@@ -25,6 +26,7 @@ import {
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
simplifyEditorBackgroundMusicPrompt,
splitEditorIconSpritesheet,
submitEditorAssetShowcase,
toggleEditorShowcaseAssetLike,
@@ -1612,6 +1614,161 @@ describe('editorProjectClient', () => {
);
});
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 补全背景音乐提示词失败',
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(3);
});
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,
},
'简化背景音乐提示词失败',
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(3);
});
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,
);
},
);
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,
);
expect(requestJsonMock.mock.calls[0]).toHaveLength(3);
},
);
it('edits editor images through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,edited',
@@ -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_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
@@ -23,6 +27,10 @@ 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';
const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing';
const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES = 256 * 1024;
@@ -493,6 +501,10 @@ export type EditorBackgroundMusicGenerationInput = {
assetLabel?: string | null;
};
export type EditorBackgroundMusicPromptAssistOptions = {
signal?: AbortSignal;
};
export type EditorAudioGenerationResult = {
audioSrc: string;
objectKey?: string | null;
@@ -1208,6 +1220,48 @@ 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,
);
}
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,
) {