From 825b0ccf6c03228f6afcd7049d94d5176f4f97ea Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 12:36:08 +0000 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E5=A2=9E=E5=8A=A0BG?= =?UTF-8?q?M=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=8A=A9=E6=89=8B=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加补全与简化内部客户端及共享DTO 实现对话框级状态、取消、迟到响应保护和交换式撤销 接入画布生成工作流并将submitting设为排他锁 补齐同步读取、项目切换和字符边界回归测试 --- packages/shared/src/contracts/editorAudio.ts | 8 + packages/shared/src/contracts/index.ts | 1 + packages/shared/src/index.ts | 1 + ...geCanvasBackgroundMusicPromptModel.test.ts | 532 ++++++++++- .../ImageCanvasBackgroundMusicPromptModel.ts | 388 +++++++- .../image-editor/ImageCanvasEditorView.tsx | 2 + .../useCanvasGenerationDialogs.test.tsx | 66 ++ .../useCanvasGenerationDialogs.ts | 14 + ...CanvasBackgroundMusicPromptAssist.test.tsx | 842 ++++++++++++++++++ ...eImageCanvasBackgroundMusicPromptAssist.ts | 640 +++++++++++++ .../useImageCanvasGenerationSurface.test.tsx | 1 + .../useImageCanvasGenerationSurface.tsx | 5 + .../useImageCanvasGenerationWorkflow.test.tsx | 1 + .../useImageCanvasGenerationWorkflow.ts | 14 + .../image-editor/editorProjectClient.test.ts | 157 ++++ .../image-editor/editorProjectClient.ts | 54 ++ 16 files changed, 2723 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/contracts/editorAudio.ts create mode 100644 src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx create mode 100644 src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts diff --git a/packages/shared/src/contracts/editorAudio.ts b/packages/shared/src/contracts/editorAudio.ts new file mode 100644 index 000000000..17e80a94f --- /dev/null +++ b/packages/shared/src/contracts/editorAudio.ts @@ -0,0 +1,8 @@ +export type BackgroundMusicPromptAssistRequest = { + currentPrompt: string; +}; + +export type BackgroundMusicPromptAssistResponse = { + prompt: string; + charCount: number; +}; diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index b9bfb4995..91d16e1ef 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -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'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a6f0f50f8..f6edff56c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts index e03955373..13176a001 100644 --- a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts @@ -4,12 +4,14 @@ import { BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS, BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS, BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS, + BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS, canCompleteBackgroundMusicPrompt, canGenerateBackgroundMusicFromPrompt, - canSimplifyBackgroundMusicPrompt, canonicalizeBackgroundMusicPrompt, + canSimplifyBackgroundMusicPrompt, countEffectivePromptCodePoints, countPromptCodePoints, + createBackgroundMusicPromptStateModel, } from './ImageCanvasBackgroundMusicPromptModel'; describe('ImageCanvasBackgroundMusicPromptModel', () => { @@ -119,10 +121,13 @@ describe('ImageCanvasBackgroundMusicPromptModel', () => { expect(canCompleteBackgroundMusicPrompt('😀'.repeat(201))).toBe(false); }); - it('allows simplification only for a non-empty canonical prompt over 200 code points', () => { + it('allows simplification only for a non-empty canonical prompt with 201-2000 code points', () => { + expect(BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS).toBe(2000); expect(canSimplifyBackgroundMusicPrompt(' \r\n\u0085')).toBe(false); expect(canSimplifyBackgroundMusicPrompt('A'.repeat(200))).toBe(false); expect(canSimplifyBackgroundMusicPrompt('A'.repeat(201))).toBe(true); + expect(canSimplifyBackgroundMusicPrompt('A'.repeat(2000))).toBe(true); + expect(canSimplifyBackgroundMusicPrompt('A'.repeat(2001))).toBe(false); }); it.each([200, 201])( @@ -206,3 +211,526 @@ describe('ImageCanvasBackgroundMusicPromptModel', () => { }, ); }); + +describe('background music Prompt dialog state model', () => { + it('claims an operation synchronously, ignores same-action double clicks, and invalidates an older operation', () => { + const model = createBackgroundMusicPromptStateModel(); + const completion = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: ' \u0085森林冒险\u3000', + }); + + expect(completion).toMatchObject({ + started: true, + reason: 'started', + canonicalPrompt: '森林冒险', + state: { + dialogId: 'dialog-a', + status: 'completing', + undoPromptSnapshot: null, + temporaryPromptSnapshot: '森林冒险', + }, + }); + expect(completion.operation).not.toBeNull(); + + const duplicate = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '另一份有效输入', + }); + expect(duplicate).toMatchObject({ + started: false, + reason: 'duplicate', + canonicalPrompt: '森林冒险', + operation: null, + }); + expect(duplicate.state.operationId).toBe(completion.operation?.operationId); + + const simplification = model.beginOperation({ + dialogId: 'dialog-a', + status: 'simplifying', + prompt: '长'.repeat(201), + }); + expect(simplification).toMatchObject({ + started: true, + reason: 'started', + canonicalPrompt: '长'.repeat(201), + state: { + status: 'simplifying', + temporaryPromptSnapshot: '长'.repeat(201), + }, + }); + expect(simplification.operation?.operationId).not.toBe( + completion.operation?.operationId, + ); + + if (!completion.operation) { + throw new Error('expected completion operation'); + } + expect( + model.resolveAiOperation(completion.operation, '迟到的补全结果'), + ).toMatchObject({ + applied: false, + prompt: null, + state: { + status: 'simplifying', + operationId: simplification.operation?.operationId, + }, + }); + }); + + it('isolates active operations and snapshots by dialog ID', () => { + const model = createBackgroundMusicPromptStateModel(); + const completion = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '森林冒险', + }); + const simplification = model.beginOperation({ + dialogId: 'dialog-b', + status: 'simplifying', + prompt: '长'.repeat(201), + }); + if (!completion.operation || !simplification.operation) { + throw new Error('expected both dialog operations'); + } + + expect( + model.resolveAiOperation(completion.operation, '森林冒险补全结果'), + ).toMatchObject({ + applied: true, + prompt: '森林冒险补全结果', + state: { + dialogId: 'dialog-a', + status: 'idle', + undoPromptSnapshot: '森林冒险', + }, + }); + expect(model.getDialogState('dialog-b')).toMatchObject({ + status: 'simplifying', + operationId: simplification.operation.operationId, + temporaryPromptSnapshot: '长'.repeat(201), + }); + + expect( + model.resolveAiOperation( + { ...completion.operation, dialogId: 'dialog-b' }, + '不能写入另一个面板', + ), + ).toMatchObject({ + applied: false, + prompt: null, + state: { + dialogId: 'dialog-b', + status: 'simplifying', + operationId: simplification.operation.operationId, + }, + }); + }); + + it('keeps a canonical temporary snapshot and promotes it only after a successful AI writeback', () => { + const model = createBackgroundMusicPromptStateModel(); + const start = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: ' \u0085森林\r\n冒险\u3000', + }); + if (!start.operation) { + throw new Error('expected completion operation'); + } + + expect(start.canonicalPrompt).toBe('森林\r\n冒险'); + expect(start.state).toMatchObject({ + status: 'completing', + undoPromptSnapshot: null, + temporaryPromptSnapshot: '森林\r\n冒险', + }); + + expect( + model.resolveAiOperation(start.operation, ' \u0085温暖森林冒险\u3000'), + ).toEqual({ + applied: true, + prompt: '温暖森林冒险', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: '森林\r\n冒险', + temporaryPromptSnapshot: null, + }, + }); + }); + + it('clears the current temporary snapshot on failure without restoring the replaced undo snapshot', () => { + const model = createBackgroundMusicPromptStateModel(); + const first = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '初始描述', + }); + if (!first.operation) { + throw new Error('expected first completion operation'); + } + model.resolveAiOperation(first.operation, '第一次补全结果'); + expect(model.getDialogState('dialog-a').undoPromptSnapshot).toBe( + '初始描述', + ); + + const second = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: ' \u0085用户手动修改\u3000', + }); + if (!second.operation) { + throw new Error('expected second completion operation'); + } + expect(second.state).toMatchObject({ + undoPromptSnapshot: null, + temporaryPromptSnapshot: '用户手动修改', + }); + + expect(model.rejectOperation(second.operation)).toEqual({ + applied: true, + prompt: '用户手动修改', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }, + }); + }); + + it('treats an invalid AI success payload as a failed writeback and never exposes it', () => { + const model = createBackgroundMusicPromptStateModel(); + const start = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '森林冒险', + }); + if (!start.operation) { + throw new Error('expected completion operation'); + } + + expect(model.resolveAiOperation(start.operation, ' \u0085\u3000')).toEqual({ + applied: false, + prompt: null, + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }, + }); + }); + + it('swaps one canonical undo snapshot repeatedly after manual edits', () => { + const model = createBackgroundMusicPromptStateModel(); + const start = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '原始描述', + }); + if (!start.operation) { + throw new Error('expected completion operation'); + } + model.resolveAiOperation(start.operation, 'AI 补全结果'); + + const firstUndo = model.swapUndoSnapshot( + 'dialog-a', + ' \u0085用户修改后的结果\u3000', + ); + expect(firstUndo).toEqual({ + applied: true, + prompt: '原始描述', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: '用户修改后的结果', + temporaryPromptSnapshot: null, + }, + }); + + const secondUndo = model.swapUndoSnapshot( + 'dialog-a', + ' \u0085原始描述\u3000', + ); + expect(secondUndo).toEqual({ + applied: true, + prompt: '用户修改后的结果', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: '原始描述', + temporaryPromptSnapshot: null, + }, + }); + }); + + it('clears the undo snapshot at the preset boundary', () => { + const model = createBackgroundMusicPromptStateModel(); + const first = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '原始描述', + }); + if (!first.operation) { + throw new Error('expected first completion operation'); + } + model.resolveAiOperation(first.operation, '第一次补全结果'); + expect(model.getDialogState('dialog-a').undoPromptSnapshot).toBe( + '原始描述', + ); + + expect( + model.preparePreset('dialog-a', ' \u0085第一次补全结果\u3000'), + ).toEqual({ + applied: true, + prompt: '第一次补全结果', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }, + }); + + const pending = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '第一次补全结果', + }); + if (!pending.operation) { + throw new Error('expected pending completion operation'); + } + expect(model.preparePreset('dialog-a', '处理期间应用新预设')).toMatchObject( + { + applied: true, + prompt: '处理期间应用新预设', + state: { + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }, + }, + ); + expect( + model.resolveAiOperation(pending.operation, '不应写回的迟到结果'), + ).toMatchObject({ + applied: false, + prompt: null, + }); + }); + + it('preserves the existing undo snapshot when a formal submission is rejected', () => { + const model = createBackgroundMusicPromptStateModel(); + const completion = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '原始描述', + }); + if (!completion.operation) { + throw new Error('expected completion operation'); + } + model.resolveAiOperation(completion.operation, 'AI 补全结果'); + + const submission = model.beginOperation({ + dialogId: 'dialog-a', + status: 'submitting', + prompt: ' \u0085AI 补全结果\u3000', + }); + if (!submission.operation) { + throw new Error('expected submission operation'); + } + expect(submission).toMatchObject({ + started: true, + canonicalPrompt: 'AI 补全结果', + state: { + status: 'submitting', + undoPromptSnapshot: '原始描述', + temporaryPromptSnapshot: null, + }, + }); + expect( + model.beginOperation({ + dialogId: 'dialog-a', + status: 'submitting', + prompt: 'AI 补全结果', + }), + ).toMatchObject({ + started: false, + reason: 'duplicate', + }); + + expect( + model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '不能覆盖正式提交', + }), + ).toMatchObject({ + started: false, + reason: 'locked', + canonicalPrompt: 'AI 补全结果', + operation: null, + state: { + status: 'submitting', + operationId: submission.operation.operationId, + undoPromptSnapshot: '原始描述', + }, + }); + expect( + model.beginOperation({ + dialogId: 'dialog-a', + status: 'simplifying', + prompt: '长'.repeat(201), + }), + ).toMatchObject({ + started: false, + reason: 'locked', + canonicalPrompt: 'AI 补全结果', + operation: null, + state: { + status: 'submitting', + operationId: submission.operation.operationId, + }, + }); + expect( + model.preparePreset('dialog-a', '不能用预设取消正式提交'), + ).toMatchObject({ + applied: false, + prompt: 'AI 补全结果', + state: { + status: 'submitting', + operationId: submission.operation.operationId, + undoPromptSnapshot: '原始描述', + }, + }); + + expect(model.rejectOperation(submission.operation)).toEqual({ + applied: true, + prompt: 'AI 补全结果', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: '原始描述', + temporaryPromptSnapshot: null, + }, + }); + + const acceptedSubmission = model.beginOperation({ + dialogId: 'dialog-a', + status: 'submitting', + prompt: 'AI 补全结果', + }); + if (!acceptedSubmission.operation) { + throw new Error('expected accepted submission operation'); + } + expect( + model.completeSubmittingOperation(acceptedSubmission.operation), + ).toEqual({ + applied: true, + prompt: 'AI 补全结果', + state: { + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: '原始描述', + temporaryPromptSnapshot: null, + }, + }); + }); + + it('accepts simplification at 2000 code points and rejects 2001 without deleting the canonical prompt', () => { + const model = createBackgroundMusicPromptStateModel(); + const atLimitPrompt = '长'.repeat(2000); + const overLimitPrompt = '长'.repeat(2001); + + expect( + model.beginOperation({ + dialogId: 'dialog-at-limit', + status: 'simplifying', + prompt: ` \u0085${atLimitPrompt}\u3000`, + }), + ).toMatchObject({ + started: true, + reason: 'started', + canonicalPrompt: atLimitPrompt, + state: { + status: 'simplifying', + temporaryPromptSnapshot: atLimitPrompt, + }, + }); + + expect( + model.beginOperation({ + dialogId: 'dialog-over-limit', + status: 'simplifying', + prompt: ` \u0085${overLimitPrompt}\u3000`, + }), + ).toEqual({ + started: false, + reason: 'ineligible', + canonicalPrompt: overLimitPrompt, + operation: null, + state: { + dialogId: 'dialog-over-limit', + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }, + }); + expect(canCompleteBackgroundMusicPrompt(overLimitPrompt)).toBe(false); + expect(canGenerateBackgroundMusicFromPrompt(overLimitPrompt)).toBe(false); + expect(canSimplifyBackgroundMusicPrompt(overLimitPrompt)).toBe(false); + }); + + it('invalidates a closed dialog operation even if the same dialog ID is reopened', () => { + const model = createBackgroundMusicPromptStateModel(); + const closedOperation = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '关闭前描述', + }); + if (!closedOperation.operation) { + throw new Error('expected completion operation'); + } + + model.closeDialog('dialog-a'); + expect(model.getDialogState('dialog-a')).toEqual({ + dialogId: 'dialog-a', + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }); + + const reopenedOperation = model.beginOperation({ + dialogId: 'dialog-a', + status: 'completing', + prompt: '重新打开描述', + }); + expect(reopenedOperation.operation?.operationId).not.toBe( + closedOperation.operation.operationId, + ); + expect( + model.resolveAiOperation(closedOperation.operation, '关闭后的迟到结果'), + ).toMatchObject({ + applied: false, + prompt: null, + state: { + status: 'completing', + operationId: reopenedOperation.operation?.operationId, + }, + }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts index 1ee89b0a0..e95166f87 100644 --- a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts @@ -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, + }; +} diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f608fe9c1..63479b699 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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, diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index fba896056..8a1c6759a 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -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(); + }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index c386825e7..f96b11cdd 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -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, diff --git a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx new file mode 100644 index 000000000..7edf10d7c --- /dev/null +++ b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx @@ -0,0 +1,842 @@ +/* @vitest-environment jsdom */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { type SetStateAction, useCallback, useRef, useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + canonicalizeBackgroundMusicPrompt, + countPromptCodePoints, +} from './ImageCanvasBackgroundMusicPromptModel'; +import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; +import { useImageCanvasBackgroundMusicPromptAssist } from './useImageCanvasBackgroundMusicPromptAssist'; + +const promptAssistClientMocks = vi.hoisted(() => ({ + complete: vi.fn(), + simplify: vi.fn(), +})); + +vi.mock('../../services/image-editor/editorProjectClient', () => ({ + completeEditorBackgroundMusicPrompt: promptAssistClientMocks.complete, + simplifyEditorBackgroundMusicPrompt: promptAssistClientMocks.simplify, +})); + +type PromptAssistResponse = { + prompt: string; + charCount: number; +}; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +type HarnessProps = { + initialDialogs: CanvasGenerationDialogState[]; + scopeKey?: string | null; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function promptAssistResponse(prompt: string): PromptAssistResponse { + const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt); + return { + prompt, + charCount: countPromptCodePoints(canonicalPrompt), + }; +} + +function createDialog( + id: string, + prompt: string, + overrides: Partial = {}, +): CanvasGenerationDialogState { + return { + id, + mode: 'audio-background-music', + prompt, + status: 'idle', + composerOpen: true, + ...overrides, + }; +} + +function requireDialog( + dialogs: CanvasGenerationDialogState[], + dialogId: string, +) { + const dialog = dialogs.find((candidate) => candidate.id === dialogId); + if (!dialog) { + throw new Error(`missing test dialog: ${dialogId}`); + } + return dialog; +} + +function usePromptAssistHarness({ initialDialogs, scopeKey }: HarnessProps) { + const [dialogs, setDialogs] = useState(initialDialogs); + const dialogsRef = useRef(dialogs); + dialogsRef.current = dialogs; + + const getCanvasGenerationDialogById = useCallback( + (dialogId: string) => + dialogsRef.current.find((dialog) => dialog.id === dialogId), + [], + ); + + const updateCanvasGenerationDialogById = useCallback( + ( + dialogId: string, + updater: ( + dialog: CanvasGenerationDialogState, + ) => CanvasGenerationDialogState | null, + ) => { + const currentDialogs = dialogsRef.current; + const currentDialog = currentDialogs.find( + (dialog) => dialog.id === dialogId, + ); + if (!currentDialog) { + return; + } + const nextDialog = updater(currentDialog); + const nextDialogs = nextDialog + ? currentDialogs.map((dialog) => + dialog.id === dialogId ? nextDialog : dialog, + ) + : currentDialogs.filter((dialog) => dialog.id !== dialogId); + dialogsRef.current = nextDialogs; + setDialogs(nextDialogs); + }, + [], + ); + + const updateDialogs = useCallback( + (updater: SetStateAction) => { + const nextDialogs = + typeof updater === 'function' ? updater(dialogsRef.current) : updater; + dialogsRef.current = nextDialogs; + setDialogs(nextDialogs); + }, + [], + ); + + const setPrompt = useCallback( + (dialogId: string, prompt: string) => { + updateCanvasGenerationDialogById(dialogId, (dialog) => ({ + ...dialog, + prompt, + })); + }, + [updateCanvasGenerationDialogById], + ); + + const setComposerOpen = useCallback( + (dialogId: string, composerOpen: boolean) => { + updateCanvasGenerationDialogById(dialogId, (dialog) => ({ + ...dialog, + composerOpen, + })); + }, + [updateCanvasGenerationDialogById], + ); + + const removeDialog = useCallback( + (dialogId: string) => { + updateDialogs((currentDialogs) => + currentDialogs.filter((dialog) => dialog.id !== dialogId), + ); + }, + [updateDialogs], + ); + + const promptAssist = useImageCanvasBackgroundMusicPromptAssist({ + canvasGenerationDialogs: dialogs, + getCanvasGenerationDialogById, + updateCanvasGenerationDialogById, + scopeKey, + }); + + return { + dialogs, + promptAssist, + setPrompt, + setComposerOpen, + removeDialog, + }; +} + +async function resolveAction( + deferred: Deferred, + responsePrompt: string, + actionPromise: Promise, +) { + let actionResult!: T; + await act(async () => { + deferred.resolve(promptAssistResponse(responsePrompt)); + actionResult = await actionPromise; + }); + return actionResult; +} + +describe('useImageCanvasBackgroundMusicPromptAssist', () => { + beforeEach(() => { + promptAssistClientMocks.complete.mockReset(); + promptAssistClientMocks.simplify.mockReset(); + }); + + it('writes the canonical Prompt before requesting and collapses a same-action double click', async () => { + const request = createDeferred(); + promptAssistClientMocks.complete.mockReturnValueOnce(request.promise); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [ + createDialog('dialog-a', ' \u0085森林冒险\u3000', { + status: 'failed', + errorMessage: '旧生成错误', + }), + ], + scopeKey: 'project-a', + }), + ); + + let firstAction!: ReturnType< + typeof result.current.promptAssist.completePrompt + >; + let duplicateAction!: ReturnType< + typeof result.current.promptAssist.completePrompt + >; + act(() => { + firstAction = result.current.promptAssist.completePrompt('dialog-a'); + duplicateAction = result.current.promptAssist.completePrompt('dialog-a'); + }); + + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险', + ); + expect(requireDialog(result.current.dialogs, 'dialog-a')).toMatchObject({ + status: 'idle', + errorMessage: undefined, + }); + expect(promptAssistClientMocks.complete).toHaveBeenCalledTimes(1); + expect(promptAssistClientMocks.complete).toHaveBeenCalledWith( + { currentPrompt: '森林冒险' }, + { signal: expect.any(AbortSignal) }, + ); + await expect(duplicateAction).resolves.toMatchObject({ + started: false, + applied: false, + reason: 'duplicate', + }); + + const outcome = await resolveAction( + request, + ' \u0085轻快的森林冒险背景音乐\u3000', + firstAction, + ); + expect(outcome).toMatchObject({ + started: true, + applied: true, + reason: 'applied', + }); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '轻快的森林冒险背景音乐', + ); + expect( + result.current.promptAssist.getDialogState('dialog-a'), + ).toMatchObject({ + status: 'idle', + undoPromptSnapshot: '森林冒险', + temporaryPromptSnapshot: null, + }); + }); + + it('aborts an older operation and discards its response even when the client ignores the signal', async () => { + const completionRequest = createDeferred(); + const simplificationRequest = createDeferred(); + let completionSignal: AbortSignal | undefined; + promptAssistClientMocks.complete.mockImplementationOnce( + ( + _input: { currentPrompt: string }, + options: { signal?: AbortSignal }, + ) => { + completionSignal = options.signal; + return completionRequest.promise; + }, + ); + promptAssistClientMocks.simplify.mockReturnValueOnce( + simplificationRequest.promise, + ); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', '森林冒险')], + }), + ); + + let completionAction!: ReturnType< + typeof result.current.promptAssist.completePrompt + >; + act(() => { + completionAction = result.current.promptAssist.completePrompt('dialog-a'); + }); + act(() => { + result.current.setPrompt('dialog-a', '长'.repeat(201)); + }); + let simplificationAction!: ReturnType< + typeof result.current.promptAssist.simplifyPrompt + >; + act(() => { + simplificationAction = + result.current.promptAssist.simplifyPrompt('dialog-a'); + }); + + expect(completionSignal?.aborted).toBe(true); + const simplificationOutcome = await resolveAction( + simplificationRequest, + '紧张推进的战斗背景音乐', + simplificationAction, + ); + expect(simplificationOutcome.reason).toBe('applied'); + + const completionOutcome = await resolveAction( + completionRequest, + '不能写回的迟到补全结果', + completionAction, + ); + expect(completionOutcome.reason).toBe('stale'); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '紧张推进的战斗背景音乐', + ); + }); + + it('drops responses after dialogs close or disappear and leaves no processing state behind', async () => { + const closeRequest = createDeferred(); + const deleteRequest = createDeferred(); + const signals = new Map(); + promptAssistClientMocks.complete.mockImplementation( + (input: { currentPrompt: string }, options: { signal: AbortSignal }) => { + signals.set(input.currentPrompt, options.signal); + return input.currentPrompt === '关闭面板' + ? closeRequest.promise + : deleteRequest.promise; + }, + ); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [ + createDialog('dialog-close', ' \u0085关闭面板\u3000'), + createDialog('dialog-delete', ' \u0085删除面板\u3000'), + ], + }), + ); + + let closeAction!: ReturnType< + typeof result.current.promptAssist.completePrompt + >; + let deleteAction!: ReturnType< + typeof result.current.promptAssist.completePrompt + >; + act(() => { + closeAction = result.current.promptAssist.completePrompt('dialog-close'); + deleteAction = + result.current.promptAssist.completePrompt('dialog-delete'); + }); + act(() => { + result.current.setComposerOpen('dialog-close', false); + result.current.removeDialog('dialog-delete'); + }); + + await waitFor(() => { + expect(signals.get('关闭面板')?.aborted).toBe(true); + expect(signals.get('删除面板')?.aborted).toBe(true); + }); + + let closeOutcome!: Awaited; + let deleteOutcome!: Awaited; + await act(async () => { + closeRequest.resolve(promptAssistResponse('关闭后的迟到结果')); + deleteRequest.resolve(promptAssistResponse('删除后的迟到结果')); + [closeOutcome, deleteOutcome] = await Promise.all([ + closeAction, + deleteAction, + ]); + }); + expect(closeOutcome.reason).toBe('stale'); + expect(deleteOutcome.reason).toBe('stale'); + expect(requireDialog(result.current.dialogs, 'dialog-close')).toMatchObject( + { + composerOpen: false, + prompt: '关闭面板', + }, + ); + expect( + result.current.dialogs.some((dialog) => dialog.id === 'dialog-delete'), + ).toBe(false); + expect( + result.current.promptAssist.getDialogState('dialog-close').status, + ).toBe('idle'); + expect( + Object.prototype.hasOwnProperty.call( + result.current.promptAssist.dialogStates, + 'dialog-delete', + ), + ).toBe(false); + }); + + it('keeps dialog responses isolated from edits in another dialog', async () => { + const request = createDeferred(); + promptAssistClientMocks.complete.mockReturnValueOnce(request.promise); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [ + createDialog('dialog-a', '森林冒险'), + createDialog('dialog-b', '城市夜晚'), + ], + }), + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + act(() => { + result.current.setPrompt('dialog-b', ' \u0085用户正在编辑 B\u3000'); + }); + + await resolveAction(request, '森林冒险补全结果', action); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险补全结果', + ); + expect(requireDialog(result.current.dialogs, 'dialog-b').prompt).toBe( + ' \u0085用户正在编辑 B\u3000', + ); + }); + + it('keeps the canonical request Prompt and clears the replaced undo snapshot after failure', async () => { + promptAssistClientMocks.complete + .mockResolvedValueOnce(promptAssistResponse('第一次补全结果')) + .mockRejectedValueOnce(new Error('LLM 暂时不可用')); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', '初始描述')], + }), + ); + + await act(async () => { + await result.current.promptAssist.completePrompt('dialog-a'); + }); + expect( + result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot, + ).toBe('初始描述'); + + act(() => { + result.current.setPrompt('dialog-a', ' \u0085用户手动修改\u3000'); + }); + let failureOutcome!: Awaited< + ReturnType + >; + await act(async () => { + failureOutcome = + await result.current.promptAssist.completePrompt('dialog-a'); + }); + + expect(failureOutcome).toMatchObject({ + applied: false, + reason: 'failed', + errorMessage: 'LLM 暂时不可用', + }); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '用户手动修改', + ); + expect( + result.current.promptAssist.getDialogState('dialog-a'), + ).toMatchObject({ + status: 'idle', + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + errorMessage: 'LLM 暂时不可用', + }); + }); + + it('keeps one successful snapshot and swaps it repeatedly after manual edits', async () => { + promptAssistClientMocks.complete.mockResolvedValueOnce( + promptAssistResponse('完整的森林冒险背景音乐'), + ); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', '森林冒险')], + }), + ); + + await act(async () => { + await result.current.promptAssist.completePrompt('dialog-a'); + }); + act(() => { + result.current.setPrompt('dialog-a', ' \u0085用户手动调整\u3000'); + }); + + let firstUndoApplied = false; + act(() => { + firstUndoApplied = result.current.promptAssist.undoPrompt('dialog-a'); + }); + expect(firstUndoApplied).toBe(true); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险', + ); + expect( + result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot, + ).toBe('用户手动调整'); + + let secondUndoApplied = false; + act(() => { + secondUndoApplied = result.current.promptAssist.undoPrompt('dialog-a'); + }); + expect(secondUndoApplied).toBe(true); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '用户手动调整', + ); + expect( + result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot, + ).toBe('森林冒险'); + }); + + it('treats the preset boundary as cancellation and clears all Prompt snapshots', async () => { + const request = createDeferred(); + let signal: AbortSignal | undefined; + promptAssistClientMocks.complete.mockImplementationOnce( + ( + _input: { currentPrompt: string }, + options: { signal?: AbortSignal }, + ) => { + signal = options.signal; + return request.promise; + }, + ); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', ' \u0085森林冒险\u3000')], + }), + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + let preparedPrompt: string | null = null; + act(() => { + preparedPrompt = result.current.promptAssist.preparePreset('dialog-a'); + }); + + expect(preparedPrompt).toBe('森林冒险'); + expect(signal?.aborted).toBe(true); + expect( + result.current.promptAssist.getDialogState('dialog-a'), + ).toMatchObject({ + status: 'idle', + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + }); + + const outcome = await resolveAction( + request, + '预设边界后的迟到结果', + action, + ); + expect(outcome.reason).toBe('stale'); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险', + ); + }); + + it('requests simplification at 2000 code points but preserves and rejects 2001', async () => { + const request = createDeferred(); + promptAssistClientMocks.simplify.mockReturnValueOnce(request.promise); + const eligiblePrompt = 'A'.repeat(2000); + const ineligiblePrompt = 'B'.repeat(2001); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [ + createDialog('dialog-eligible', ` \u0085${eligiblePrompt}\u3000`), + createDialog('dialog-ineligible', ` \u0085${ineligiblePrompt}\u3000`), + ], + }), + ); + + let eligibleAction!: ReturnType< + typeof result.current.promptAssist.simplifyPrompt + >; + let ineligibleAction!: ReturnType< + typeof result.current.promptAssist.simplifyPrompt + >; + act(() => { + eligibleAction = + result.current.promptAssist.simplifyPrompt('dialog-eligible'); + ineligibleAction = + result.current.promptAssist.simplifyPrompt('dialog-ineligible'); + }); + + expect(promptAssistClientMocks.simplify).toHaveBeenCalledTimes(1); + expect(promptAssistClientMocks.simplify).toHaveBeenCalledWith( + { currentPrompt: eligiblePrompt }, + { signal: expect.any(AbortSignal) }, + ); + expect( + requireDialog(result.current.dialogs, 'dialog-eligible').prompt, + ).toBe(eligiblePrompt); + expect( + requireDialog(result.current.dialogs, 'dialog-ineligible').prompt, + ).toBe(ineligiblePrompt); + await expect(ineligibleAction).resolves.toMatchObject({ + started: false, + reason: 'ineligible', + }); + + const outcome = await resolveAction( + request, + '合法的简化结果', + eligibleAction, + ); + expect(outcome.reason).toBe('applied'); + }); + + it('aborts and invalidates an active request when scopeKey changes', async () => { + const oldScopeRequest = createDeferred(); + const newScopeRequest = createDeferred(); + let signal: AbortSignal | undefined; + promptAssistClientMocks.complete + .mockImplementationOnce( + ( + _input: { currentPrompt: string }, + options: { signal?: AbortSignal }, + ) => { + signal = options.signal; + return oldScopeRequest.promise; + }, + ) + .mockReturnValueOnce(newScopeRequest.promise); + const initialDialogs = [createDialog('dialog-a', ' \u0085森林冒险\u3000')]; + const { result, rerender } = renderHook( + ({ scopeKey }: { scopeKey: string }) => + usePromptAssistHarness({ + initialDialogs, + scopeKey, + }), + { + initialProps: { scopeKey: 'project-a' }, + }, + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + rerender({ scopeKey: 'project-b' }); + + expect(signal?.aborted).toBe(true); + expect(result.current.promptAssist.dialogStates).toEqual({}); + let newScopeAction!: ReturnType< + typeof result.current.promptAssist.completePrompt + >; + act(() => { + newScopeAction = result.current.promptAssist.completePrompt('dialog-a'); + }); + + const oldScopeOutcome = await resolveAction( + oldScopeRequest, + '旧工程的迟到结果', + action, + ); + expect(oldScopeOutcome.reason).toBe('stale'); + expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe( + 'completing', + ); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险', + ); + + const newScopeOutcome = await resolveAction( + newScopeRequest, + '新工程的有效结果', + newScopeAction, + ); + expect(newScopeOutcome.reason).toBe('applied'); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '新工程的有效结果', + ); + }); + + it('aborts on unmount and discards a client response that still resolves', async () => { + const request = createDeferred(); + let signal: AbortSignal | undefined; + promptAssistClientMocks.complete.mockImplementationOnce( + ( + _input: { currentPrompt: string }, + options: { signal?: AbortSignal }, + ) => { + signal = options.signal; + return request.promise; + }, + ); + const { result, unmount } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', '森林冒险')], + }), + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + unmount(); + expect(signal?.aborted).toBe(true); + + request.resolve(promptAssistResponse('卸载后的迟到结果')); + await expect(action).resolves.toMatchObject({ + applied: false, + reason: 'stale', + }); + }); + + it('preserves undo on submission rejection and ignores finishes for closed or deleted dialogs', async () => { + promptAssistClientMocks.complete.mockResolvedValueOnce( + promptAssistResponse('完整的森林冒险背景音乐'), + ); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [ + createDialog('dialog-reject', '森林冒险'), + createDialog('dialog-close', '关闭前描述'), + createDialog('dialog-delete', '删除前描述'), + ], + }), + ); + + await act(async () => { + await result.current.promptAssist.completePrompt('dialog-reject'); + }); + act(() => { + result.current.setPrompt('dialog-reject', ' \u0085提交前修改\u3000'); + }); + let rejectedClaim!: NonNullable< + ReturnType + >; + act(() => { + rejectedClaim = + result.current.promptAssist.beginSubmission('dialog-reject')!; + }); + let blockedCompletion!: Awaited< + ReturnType + >; + let blockedSimplification!: Awaited< + ReturnType + >; + await act(async () => { + blockedCompletion = + await result.current.promptAssist.completePrompt('dialog-reject'); + blockedSimplification = + await result.current.promptAssist.simplifyPrompt('dialog-reject'); + }); + let preparedDuringSubmission: string | null = ''; + let undoDuringSubmission = true; + act(() => { + preparedDuringSubmission = + result.current.promptAssist.preparePreset('dialog-reject'); + undoDuringSubmission = + result.current.promptAssist.undoPrompt('dialog-reject'); + }); + + expect(blockedCompletion.reason).toBe('locked'); + expect(blockedSimplification.reason).toBe('locked'); + expect(promptAssistClientMocks.complete).toHaveBeenCalledTimes(1); + expect(promptAssistClientMocks.simplify).not.toHaveBeenCalled(); + expect(preparedDuringSubmission).toBeNull(); + expect(undoDuringSubmission).toBe(false); + expect(requireDialog(result.current.dialogs, 'dialog-reject').prompt).toBe( + '提交前修改', + ); + expect( + result.current.promptAssist.getDialogState('dialog-reject'), + ).toMatchObject({ + status: 'submitting', + operationId: rejectedClaim.operation.operationId, + undoPromptSnapshot: '森林冒险', + }); + + let rejectionApplied = false; + act(() => { + rejectionApplied = result.current.promptAssist.finishSubmission({ + operation: rejectedClaim.operation, + accepted: false, + }); + }); + + expect(rejectionApplied).toBe(true); + expect(requireDialog(result.current.dialogs, 'dialog-reject').prompt).toBe( + '提交前修改', + ); + expect( + result.current.promptAssist.getDialogState('dialog-reject'), + ).toMatchObject({ + status: 'idle', + undoPromptSnapshot: '森林冒险', + }); + + let closeClaim!: NonNullable< + ReturnType + >; + let deleteClaim!: NonNullable< + ReturnType + >; + act(() => { + closeClaim = result.current.promptAssist.beginSubmission('dialog-close')!; + deleteClaim = + result.current.promptAssist.beginSubmission('dialog-delete')!; + }); + act(() => { + result.current.setComposerOpen('dialog-close', false); + result.current.removeDialog('dialog-delete'); + }); + await waitFor(() => { + expect( + result.current.promptAssist.getDialogState('dialog-close').status, + ).toBe('idle'); + expect( + Object.prototype.hasOwnProperty.call( + result.current.promptAssist.dialogStates, + 'dialog-delete', + ), + ).toBe(false); + }); + + const statesBeforeFinish = result.current.promptAssist.dialogStates; + let closeFinished = true; + let deleteFinished = true; + act(() => { + closeFinished = result.current.promptAssist.finishSubmission({ + operation: closeClaim.operation, + accepted: false, + }); + deleteFinished = result.current.promptAssist.finishSubmission({ + operation: deleteClaim.operation, + accepted: true, + }); + }); + + expect(closeFinished).toBe(false); + expect(deleteFinished).toBe(false); + expect(result.current.promptAssist.dialogStates).toBe(statesBeforeFinish); + expect( + requireDialog(result.current.dialogs, 'dialog-close').composerOpen, + ).toBe(false); + expect( + result.current.dialogs.some((dialog) => dialog.id === 'dialog-delete'), + ).toBe(false); + }); +}); diff --git a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts new file mode 100644 index 000000000..716dca358 --- /dev/null +++ b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts @@ -0,0 +1,640 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + completeEditorBackgroundMusicPrompt, + simplifyEditorBackgroundMusicPrompt, +} from '../../services/image-editor/editorProjectClient'; +import { + type BackgroundMusicPromptDialogState, + type BackgroundMusicPromptOperation, + type BackgroundMusicPromptOperationStatus, + canonicalizeBackgroundMusicPrompt, + countPromptCodePoints, + createBackgroundMusicPromptStateModel, +} from './ImageCanvasBackgroundMusicPromptModel'; +import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; + +type CanvasGenerationDialogUpdater = ( + dialog: CanvasGenerationDialogState, +) => CanvasGenerationDialogState | null; + +type ActivePromptOperation = { + operation: BackgroundMusicPromptOperation; + abortController: AbortController | null; + scopeKey: string | null | undefined; +}; + +export type BackgroundMusicPromptAssistDialogState = + BackgroundMusicPromptDialogState & { + errorMessage: string | null; + }; + +export type BackgroundMusicPromptAssistActionResult = { + started: boolean; + applied: boolean; + reason: + | 'applied' + | 'duplicate' + | 'ineligible' + | 'locked' + | 'stale' + | 'failed'; + errorMessage: string | null; +}; + +export type BackgroundMusicPromptSubmissionClaim = { + operation: BackgroundMusicPromptOperation; + prompt: string; +}; + +function isBackgroundMusicDialog( + dialog: CanvasGenerationDialogState | undefined, +): dialog is CanvasGenerationDialogState & { + mode: 'audio-background-music'; +} { + return dialog?.mode === 'audio-background-music'; +} + +function isAbortError(error: unknown) { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +function resolvePromptAssistErrorMessage(error: unknown) { + if (isAbortError(error)) { + return null; + } + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + return 'AI 暂时无法处理背景音乐描述,请稍后重试'; +} + +function invalidPromptAssistResponseError() { + return new Error('AI 返回的背景音乐描述无效,请稍后重试'); +} + +export function useImageCanvasBackgroundMusicPromptAssist({ + canvasGenerationDialogs, + getCanvasGenerationDialogById, + updateCanvasGenerationDialogById, + scopeKey, +}: { + canvasGenerationDialogs: CanvasGenerationDialogState[]; + getCanvasGenerationDialogById: ( + dialogId: string, + ) => CanvasGenerationDialogState | undefined; + updateCanvasGenerationDialogById: ( + dialogId: string, + updater: CanvasGenerationDialogUpdater, + ) => void; + scopeKey?: string | null; +}) { + const modelRef = useRef(createBackgroundMusicPromptStateModel()); + const activeOperationsRef = useRef(new Map()); + const errorMessagesRef = useRef(new Map()); + const knownDialogIdsRef = useRef(new Set()); + const scopeKeyRef = useRef(scopeKey); + const previousScopeKeyRef = useRef(scopeKey); + const [dialogStates, setDialogStates] = useState< + Record + >({}); + + scopeKeyRef.current = scopeKey; + + const publishDialogState = useCallback((dialogId: string) => { + const state = modelRef.current.getDialogState(dialogId); + const nextState: BackgroundMusicPromptAssistDialogState = { + ...state, + errorMessage: errorMessagesRef.current.get(dialogId) ?? null, + }; + setDialogStates((currentStates) => ({ + ...currentStates, + [dialogId]: nextState, + })); + return nextState; + }, []); + + const isSubmissionLocked = useCallback( + (dialogId: string) => + modelRef.current.getDialogState(dialogId).status === 'submitting' || + activeOperationsRef.current.get(dialogId)?.operation.status === + 'submitting', + [], + ); + + const updateDialogPrompt = useCallback( + (dialogId: string, prompt: string) => { + updateCanvasGenerationDialogById(dialogId, (dialog) => + dialog.mode === 'audio-background-music' + ? { + ...dialog, + prompt, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: + dialog.status === 'failed' ? undefined : dialog.errorMessage, + } + : dialog, + ); + }, + [updateCanvasGenerationDialogById], + ); + + const clearActiveOperation = useCallback( + ( + dialogId: string, + options: { + abort: boolean; + removeDialogState: boolean; + }, + ) => { + const activeOperation = activeOperationsRef.current.get(dialogId); + if (activeOperation) { + if (options.abort) { + activeOperation.abortController?.abort(); + } + const resolution = modelRef.current.rejectOperation( + activeOperation.operation, + ); + activeOperationsRef.current.delete(dialogId); + if ( + !options.removeDialogState && + resolution.applied && + resolution.prompt + ) { + updateDialogPrompt(dialogId, resolution.prompt); + } + } + errorMessagesRef.current.delete(dialogId); + if (options.removeDialogState) { + modelRef.current.closeDialog(dialogId); + setDialogStates((currentStates) => { + if (!(dialogId in currentStates)) { + return currentStates; + } + const nextStates = { ...currentStates }; + delete nextStates[dialogId]; + return nextStates; + }); + return; + } + if (activeOperation) { + publishDialogState(dialogId); + } + }, + [publishDialogState, updateDialogPrompt], + ); + + useEffect(() => { + const currentDialogIds = new Set( + canvasGenerationDialogs + .filter(isBackgroundMusicDialog) + .map((dialog) => dialog.id), + ); + + for (const dialogId of knownDialogIdsRef.current) { + if (!currentDialogIds.has(dialogId)) { + clearActiveOperation(dialogId, { + abort: true, + removeDialogState: true, + }); + } + } + + for (const [dialogId] of activeOperationsRef.current) { + const dialog = getCanvasGenerationDialogById(dialogId); + if (!isBackgroundMusicDialog(dialog) || dialog.composerOpen === false) { + clearActiveOperation(dialogId, { + abort: true, + removeDialogState: !isBackgroundMusicDialog(dialog), + }); + } + } + + knownDialogIdsRef.current = currentDialogIds; + }, [ + canvasGenerationDialogs, + clearActiveOperation, + getCanvasGenerationDialogById, + ]); + + useEffect(() => { + if (Object.is(previousScopeKeyRef.current, scopeKey)) { + return; + } + previousScopeKeyRef.current = scopeKey; + for (const activeOperation of activeOperationsRef.current.values()) { + activeOperation.abortController?.abort(); + } + activeOperationsRef.current.clear(); + errorMessagesRef.current.clear(); + modelRef.current.reset(); + knownDialogIdsRef.current = new Set( + canvasGenerationDialogs + .filter(isBackgroundMusicDialog) + .map((dialog) => dialog.id), + ); + setDialogStates({}); + }, [canvasGenerationDialogs, scopeKey]); + + useEffect( + () => () => { + for (const activeOperation of activeOperationsRef.current.values()) { + activeOperation.abortController?.abort(); + } + activeOperationsRef.current.clear(); + }, + [], + ); + + const runAiOperation = useCallback( + async ( + dialogId: string, + status: Extract< + BackgroundMusicPromptOperationStatus, + 'completing' | 'simplifying' + >, + ): Promise => { + const dialog = getCanvasGenerationDialogById(dialogId); + if (!isBackgroundMusicDialog(dialog) || dialog.composerOpen === false) { + return { + started: false, + applied: false, + reason: 'stale', + errorMessage: null, + }; + } + + if (isSubmissionLocked(dialogId)) { + return { + started: false, + applied: false, + reason: 'locked', + errorMessage: null, + }; + } + + const canonicalPrompt = canonicalizeBackgroundMusicPrompt(dialog.prompt); + updateDialogPrompt(dialogId, canonicalPrompt); + const start = modelRef.current.beginOperation({ + dialogId, + status, + prompt: canonicalPrompt, + }); + if (!start.started || !start.operation) { + publishDialogState(dialogId); + return { + started: false, + applied: false, + reason: start.reason === 'started' ? 'ineligible' : start.reason, + errorMessage: null, + }; + } + + const previousOperation = activeOperationsRef.current.get(dialogId); + previousOperation?.abortController?.abort(); + + const abortController = new AbortController(); + const operation = start.operation; + const operationScopeKey = scopeKeyRef.current; + activeOperationsRef.current.set(dialogId, { + operation, + abortController, + scopeKey: operationScopeKey, + }); + errorMessagesRef.current.delete(dialogId); + publishDialogState(dialogId); + + try { + const response = + status === 'completing' + ? await completeEditorBackgroundMusicPrompt( + { currentPrompt: operation.canonicalPrompt }, + { signal: abortController.signal }, + ) + : await simplifyEditorBackgroundMusicPrompt( + { currentPrompt: operation.canonicalPrompt }, + { signal: abortController.signal }, + ); + + const activeOperation = activeOperationsRef.current.get(dialogId); + const latestDialog = getCanvasGenerationDialogById(dialogId); + if ( + activeOperation?.operation.operationId !== operation.operationId || + !Object.is(activeOperation.scopeKey, operationScopeKey) || + !Object.is(operationScopeKey, scopeKeyRef.current) + ) { + return { + started: true, + applied: false, + reason: 'stale', + errorMessage: null, + }; + } + if ( + !isBackgroundMusicDialog(latestDialog) || + latestDialog.composerOpen === false + ) { + clearActiveOperation(dialogId, { + abort: false, + removeDialogState: !isBackgroundMusicDialog(latestDialog), + }); + return { + started: true, + applied: false, + reason: 'stale', + errorMessage: null, + }; + } + + const canonicalPrompt = canonicalizeBackgroundMusicPrompt( + response.prompt, + ); + if ( + !Number.isInteger(response.charCount) || + response.charCount !== countPromptCodePoints(canonicalPrompt) + ) { + throw invalidPromptAssistResponseError(); + } + const resolution = modelRef.current.resolveAiOperation( + operation, + canonicalPrompt, + ); + activeOperationsRef.current.delete(dialogId); + if (!resolution.applied || !resolution.prompt) { + errorMessagesRef.current.set( + dialogId, + invalidPromptAssistResponseError().message, + ); + publishDialogState(dialogId); + return { + started: true, + applied: false, + reason: 'failed', + errorMessage: invalidPromptAssistResponseError().message, + }; + } + + errorMessagesRef.current.delete(dialogId); + updateDialogPrompt(dialogId, resolution.prompt); + publishDialogState(dialogId); + return { + started: true, + applied: true, + reason: 'applied', + errorMessage: null, + }; + } catch (error) { + const activeOperation = activeOperationsRef.current.get(dialogId); + if ( + activeOperation?.operation.operationId !== operation.operationId || + !Object.is(activeOperation.scopeKey, operationScopeKey) || + !Object.is(operationScopeKey, scopeKeyRef.current) + ) { + return { + started: true, + applied: false, + reason: 'stale', + errorMessage: null, + }; + } + + const latestDialog = getCanvasGenerationDialogById(dialogId); + if ( + !isBackgroundMusicDialog(latestDialog) || + latestDialog.composerOpen === false + ) { + clearActiveOperation(dialogId, { + abort: false, + removeDialogState: !isBackgroundMusicDialog(latestDialog), + }); + return { + started: true, + applied: false, + reason: 'stale', + errorMessage: null, + }; + } + + const resolution = modelRef.current.rejectOperation(operation); + activeOperationsRef.current.delete(dialogId); + if (resolution.applied && resolution.prompt) { + updateDialogPrompt(dialogId, resolution.prompt); + } + const errorMessage = resolvePromptAssistErrorMessage(error); + if (errorMessage) { + errorMessagesRef.current.set(dialogId, errorMessage); + } else { + errorMessagesRef.current.delete(dialogId); + } + publishDialogState(dialogId); + return { + started: true, + applied: false, + reason: errorMessage ? 'failed' : 'stale', + errorMessage, + }; + } finally { + const activeOperation = activeOperationsRef.current.get(dialogId); + if ( + activeOperation?.operation.operationId === operation.operationId && + Object.is(activeOperation.scopeKey, operationScopeKey) + ) { + activeOperationsRef.current.delete(dialogId); + } + } + }, + [ + clearActiveOperation, + getCanvasGenerationDialogById, + isSubmissionLocked, + publishDialogState, + updateDialogPrompt, + ], + ); + + const completePrompt = useCallback( + (dialogId: string) => runAiOperation(dialogId, 'completing'), + [runAiOperation], + ); + + const simplifyPrompt = useCallback( + (dialogId: string) => runAiOperation(dialogId, 'simplifying'), + [runAiOperation], + ); + + const preparePreset = useCallback( + (dialogId: string) => { + const dialog = getCanvasGenerationDialogById(dialogId); + if (!isBackgroundMusicDialog(dialog) || dialog.composerOpen === false) { + return null; + } + if (isSubmissionLocked(dialogId)) { + return null; + } + const previousOperation = activeOperationsRef.current.get(dialogId); + previousOperation?.abortController?.abort(); + activeOperationsRef.current.delete(dialogId); + const canonicalPrompt = canonicalizeBackgroundMusicPrompt(dialog.prompt); + updateDialogPrompt(dialogId, canonicalPrompt); + const prepared = modelRef.current.preparePreset( + dialogId, + canonicalPrompt, + ); + errorMessagesRef.current.delete(dialogId); + publishDialogState(dialogId); + return prepared.prompt; + }, + [ + getCanvasGenerationDialogById, + isSubmissionLocked, + publishDialogState, + updateDialogPrompt, + ], + ); + + const undoPrompt = useCallback( + (dialogId: string) => { + const dialog = getCanvasGenerationDialogById(dialogId); + if (!isBackgroundMusicDialog(dialog) || dialog.composerOpen === false) { + return false; + } + const currentState = modelRef.current.getDialogState(dialogId); + if ( + currentState.status !== 'idle' || + currentState.undoPromptSnapshot === null + ) { + return false; + } + const canonicalPrompt = canonicalizeBackgroundMusicPrompt(dialog.prompt); + updateDialogPrompt(dialogId, canonicalPrompt); + const swapped = modelRef.current.swapUndoSnapshot( + dialogId, + canonicalPrompt, + ); + if (!swapped.applied) { + return false; + } + errorMessagesRef.current.delete(dialogId); + updateDialogPrompt(dialogId, swapped.prompt); + publishDialogState(dialogId); + return true; + }, + [getCanvasGenerationDialogById, publishDialogState, updateDialogPrompt], + ); + + const beginSubmission = useCallback( + (dialogId: string): BackgroundMusicPromptSubmissionClaim | null => { + const dialog = getCanvasGenerationDialogById(dialogId); + if (!isBackgroundMusicDialog(dialog) || dialog.composerOpen === false) { + return null; + } + const start = modelRef.current.beginOperation({ + dialogId, + status: 'submitting', + prompt: dialog.prompt, + }); + if (start.reason !== 'duplicate') { + updateDialogPrompt(dialogId, start.canonicalPrompt); + } + if (!start.started || !start.operation) { + publishDialogState(dialogId); + return null; + } + const previousOperation = activeOperationsRef.current.get(dialogId); + previousOperation?.abortController?.abort(); + activeOperationsRef.current.set(dialogId, { + operation: start.operation, + abortController: null, + scopeKey: scopeKeyRef.current, + }); + errorMessagesRef.current.delete(dialogId); + publishDialogState(dialogId); + return { + operation: start.operation, + prompt: start.canonicalPrompt, + }; + }, + [getCanvasGenerationDialogById, publishDialogState, updateDialogPrompt], + ); + + const finishSubmission = useCallback( + ({ + operation, + accepted, + }: { + operation: BackgroundMusicPromptOperation; + accepted: boolean; + }) => { + const activeOperation = activeOperationsRef.current.get( + operation.dialogId, + ); + if (activeOperation?.operation.operationId !== operation.operationId) { + return false; + } + const latestDialog = getCanvasGenerationDialogById(operation.dialogId); + if ( + !Object.is(activeOperation.scopeKey, scopeKeyRef.current) || + !isBackgroundMusicDialog(latestDialog) || + latestDialog.composerOpen === false + ) { + clearActiveOperation(operation.dialogId, { + abort: false, + removeDialogState: !isBackgroundMusicDialog(latestDialog), + }); + return false; + } + const resolution = accepted + ? modelRef.current.completeSubmittingOperation(operation) + : modelRef.current.rejectOperation(operation); + activeOperationsRef.current.delete(operation.dialogId); + if (resolution.applied && resolution.prompt) { + updateDialogPrompt(operation.dialogId, resolution.prompt); + } + publishDialogState(operation.dialogId); + return resolution.applied; + }, + [ + clearActiveOperation, + getCanvasGenerationDialogById, + publishDialogState, + updateDialogPrompt, + ], + ); + + const getDialogState = useCallback( + (dialogId: string): BackgroundMusicPromptAssistDialogState => + dialogStates[dialogId] ?? { + ...modelRef.current.getDialogState(dialogId), + errorMessage: errorMessagesRef.current.get(dialogId) ?? null, + }, + [dialogStates], + ); + + return useMemo( + () => ({ + dialogStates, + getDialogState, + completePrompt, + simplifyPrompt, + preparePreset, + undoPrompt, + beginSubmission, + finishSubmission, + }), + [ + beginSubmission, + completePrompt, + dialogStates, + finishSubmission, + getDialogState, + preparePreset, + simplifyPrompt, + undoPrompt, + ], + ); +} diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index fab101b69..aa65b708f 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -154,6 +154,7 @@ function GenerationSurfaceHarness() { openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, + getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog: dialogs.archiveActiveCanvasGenerationDialog, removeCanvasGenerationDialogsByLayerId: diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index a95b296c7..b4d2f0673 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -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, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 68ddc80a9..f773e63c4 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -158,6 +158,7 @@ function GenerationWorkflowHarness({ openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, + getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: dialogs.removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index e40b1e752..acc8f01ea 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -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()); 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, diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index ce9133c08..4c046db42 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -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[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[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', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index a830d8095..e3838cd8d 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -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( + 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, ) {