前端:增加BGM提示词助手状态与客户端
增加补全与简化内部客户端及共享DTO 实现对话框级状态、取消、迟到响应保护和交换式撤销 接入画布生成工作流并将submitting设为排他锁 补齐同步读取、项目切换和字符边界回归测试
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export type BackgroundMusicPromptAssistRequest = {
|
||||
currentPrompt: string;
|
||||
};
|
||||
|
||||
export type BackgroundMusicPromptAssistResponse = {
|
||||
prompt: string;
|
||||
charCount: number;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user