diff --git a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx index 7edf10d7c..e86181348 100644 --- a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx +++ b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx @@ -1,7 +1,16 @@ /* @vitest-environment jsdom */ import { act, renderHook, waitFor } from '@testing-library/react'; -import { type SetStateAction, useCallback, useRef, useState } from 'react'; +import { + type ReactNode, + type SetStateAction, + StrictMode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -34,7 +43,8 @@ type Deferred = { type HarnessProps = { initialDialogs: CanvasGenerationDialogState[]; - scopeKey?: string | null; + currentUserId?: string | null; + projectId?: string | null; }; function createDeferred(): Deferred { @@ -81,7 +91,26 @@ function requireDialog( return dialog; } -function usePromptAssistHarness({ initialDialogs, scopeKey }: HarnessProps) { +/** + * 记录同一次 commit 内的相位顺序。scope 清理必须发生在 commit 的 layout 阶段: + * 排到 passive 阶段就会留出“新 scope 已 commit、旧 operation 仍有效”的窗口, + * 而 Testing Library 的 rerender / act 会自动冲刷 passive effect,行为断言覆盖不到。 + */ +function ScopePhaseProbe({ record }: { record: (phase: string) => void }) { + useLayoutEffect(() => { + record('probe-layout'); + }); + useEffect(() => { + record('probe-passive'); + }); + return null; +} + +function usePromptAssistHarness({ + initialDialogs, + currentUserId, + projectId, +}: HarnessProps) { const [dialogs, setDialogs] = useState(initialDialogs); const dialogsRef = useRef(dialogs); dialogsRef.current = dialogs; @@ -161,7 +190,8 @@ function usePromptAssistHarness({ initialDialogs, scopeKey }: HarnessProps) { canvasGenerationDialogs: dialogs, getCanvasGenerationDialogById, updateCanvasGenerationDialogById, - scopeKey, + currentUserId, + projectId, }); return { @@ -203,7 +233,8 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => { errorMessage: '旧生成错误', }), ], - scopeKey: 'project-a', + currentUserId: 'user-a', + projectId: 'project-a', }), ); @@ -606,7 +637,7 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => { expect(outcome.reason).toBe('applied'); }); - it('aborts and invalidates an active request when scopeKey changes', async () => { + it('aborts and invalidates an active request when projectId changes', async () => { const oldScopeRequest = createDeferred(); const newScopeRequest = createDeferred(); let signal: AbortSignal | undefined; @@ -623,13 +654,14 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => { .mockReturnValueOnce(newScopeRequest.promise); const initialDialogs = [createDialog('dialog-a', ' \u0085森林冒险\u3000')]; const { result, rerender } = renderHook( - ({ scopeKey }: { scopeKey: string }) => + ({ projectId }: { projectId: string }) => usePromptAssistHarness({ initialDialogs, - scopeKey, + currentUserId: 'user-a', + projectId, }), { - initialProps: { scopeKey: 'project-a' }, + initialProps: { projectId: 'project-a' }, }, ); @@ -637,7 +669,7 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => { act(() => { action = result.current.promptAssist.completePrompt('dialog-a'); }); - rerender({ scopeKey: 'project-b' }); + rerender({ projectId: 'project-b' }); expect(signal?.aborted).toBe(true); expect(result.current.promptAssist.dialogStates).toEqual({}); @@ -672,6 +704,274 @@ describe('useImageCanvasBackgroundMusicPromptAssist', () => { ); }); + it('aborts requests and clears snapshots when only currentUserId changes', async () => { + const staleRequest = createDeferred(); + let signal: AbortSignal | undefined; + promptAssistClientMocks.complete + .mockResolvedValueOnce(promptAssistResponse('完整的森林冒险背景音乐')) + .mockImplementationOnce( + ( + _input: { currentPrompt: string }, + options: { signal?: AbortSignal }, + ) => { + signal = options.signal; + return staleRequest.promise; + }, + ); + const initialDialogs = [createDialog('dialog-a', '森林冒险')]; + const { result, rerender } = renderHook( + ({ currentUserId }: { currentUserId: string }) => + usePromptAssistHarness({ + initialDialogs, + currentUserId, + projectId: 'project-a', + }), + { + initialProps: { currentUserId: 'user-a' }, + }, + ); + + await act(async () => { + await result.current.promptAssist.completePrompt('dialog-a'); + }); + + expect( + result.current.promptAssist.getDialogState('dialog-a') + .undoPromptSnapshot, + ).toBe('森林冒险'); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + rerender({ currentUserId: 'user-b' }); + + expect(signal?.aborted).toBe(true); + expect(result.current.promptAssist.dialogStates).toEqual({}); + expect( + result.current.promptAssist.getDialogState('dialog-a'), + ).toMatchObject({ + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + errorMessage: null, + }); + + const staleOutcome = await resolveAction( + staleRequest, + '旧账号的迟到结果', + action, + ); + + expect(staleOutcome.reason).toBe('stale'); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '完整的森林冒险背景音乐', + ); + expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject( + { + status: 'idle', + undoPromptSnapshot: null, + }, + ); + }); + + it('invalidates old-scope operations inside the commit, before any passive effect runs', async () => { + const staleRequest = createDeferred(); + let signal: AbortSignal | undefined; + promptAssistClientMocks.complete.mockImplementationOnce( + ( + _input: { currentPrompt: string }, + options: { signal?: AbortSignal }, + ) => { + signal = options.signal; + return staleRequest.promise; + }, + ); + const phases: string[] = []; + const initialDialogs = [createDialog('dialog-a', '森林冒险')]; + const { result, rerender } = renderHook( + ({ currentUserId }: { currentUserId: string }) => + usePromptAssistHarness({ + initialDialogs, + currentUserId, + projectId: 'project-a', + }), + { + initialProps: { currentUserId: 'user-a' }, + wrapper: ({ children }: { children: ReactNode }) => ( + <> + phases.push(phase)} /> + {children} + + ), + }, + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + signal?.addEventListener('abort', () => phases.push('abort'), { + once: true, + }); + // 只观察 scope 切换这一次 commit 的相位顺序。 + phases.length = 0; + rerender({ currentUserId: 'user-b' }); + + expect(phases[0]).toBe('probe-layout'); + expect(phases).toContain('abort'); + expect(phases.indexOf('abort')).toBeLessThan( + phases.indexOf('probe-passive'), + ); + expect(signal?.aborted).toBe(true); + expect(result.current.promptAssist.dialogStates).toEqual({}); + + const staleOutcome = await resolveAction( + staleRequest, + '旧账号的迟到结果', + action, + ); + + expect(staleOutcome.reason).toBe('stale'); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险', + ); + }); + + it('keeps an in-flight operation valid across renders that recreate equal scope props', async () => { + const request = createDeferred(); + promptAssistClientMocks.complete.mockReturnValueOnce(request.promise); + const initialDialogs = [createDialog('dialog-a', '森林冒险')]; + const { result, rerender } = renderHook( + ({ currentUserId, projectId }: HarnessProps) => + usePromptAssistHarness({ + initialDialogs, + currentUserId, + projectId, + }), + { + initialProps: { + initialDialogs, + currentUserId: 'user-a', + projectId: 'project-a', + }, + }, + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + // scope 身份必须按值判定:每次 render 重新构造等值 props 不得让运行中的 + // operation 变成 stale。 + rerender({ + initialDialogs, + currentUserId: 'user-a', + projectId: 'project-a', + }); + + const outcome = await resolveAction( + request, + '完整的森林冒险背景音乐', + action, + ); + + expect(outcome.reason).toBe('applied'); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '完整的森林冒险背景音乐', + ); + expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject( + { + status: 'idle', + undoPromptSnapshot: '森林冒险', + }, + ); + }); + + it('completes without sticking in a busy state under StrictMode double rendering', async () => { + const request = createDeferred(); + promptAssistClientMocks.complete.mockReturnValueOnce(request.promise); + const { result } = renderHook( + () => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', '森林冒险')], + currentUserId: 'user-a', + projectId: 'project-a', + }), + { wrapper: StrictMode }, + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + + expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe( + 'completing', + ); + + const outcome = await resolveAction( + request, + '完整的森林冒险背景音乐', + action, + ); + + expect(outcome.reason).toBe('applied'); + expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject( + { + status: 'idle', + operationId: null, + undoPromptSnapshot: '森林冒险', + }, + ); + }); + + it('returns to idle without an undo snapshot when the browser request times out', async () => { + const request = createDeferred(); + promptAssistClientMocks.complete.mockReturnValueOnce(request.promise); + const { result } = renderHook(() => + usePromptAssistHarness({ + initialDialogs: [createDialog('dialog-a', ' …森林冒险 ')], + currentUserId: 'user-a', + projectId: 'project-a', + }), + ); + + let action!: ReturnType; + act(() => { + action = result.current.promptAssist.completePrompt('dialog-a'); + }); + + const timeoutError = Object.assign(new Error('请求超时:180000ms'), { + name: 'TimeoutError', + }); + let outcome!: Awaited; + await act(async () => { + request.reject(timeoutError); + outcome = await action; + }); + + expect(outcome).toMatchObject({ + started: true, + applied: false, + reason: 'failed', + errorMessage: '请求超时:180000ms', + }); + expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe( + '森林冒险', + ); + expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject( + { + status: 'idle', + operationId: null, + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + errorMessage: '请求超时:180000ms', + }, + ); + }); + it('aborts on unmount and discards a client response that still resolves', async () => { const request = createDeferred(); let signal: AbortSignal | undefined; diff --git a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts index 716dca358..81e85d464 100644 --- a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts +++ b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { completeEditorBackgroundMusicPrompt, @@ -78,11 +85,23 @@ function invalidPromptAssistResponseError() { return new Error('AI 返回的背景音乐描述无效,请稍后重试'); } +/** + * Prompt 助手临时状态同时属于当前账号和当前项目,任一侧切换都必须让旧 operation + * 失效。这里把两者压成一个稳定字符串,调用方按引用比较对象时不会误判 scope 变化。 + */ +function resolveBackgroundMusicPromptScopeKey( + currentUserId: string | null | undefined, + projectId: string | null | undefined, +) { + return JSON.stringify([currentUserId ?? null, projectId ?? null]); +} + export function useImageCanvasBackgroundMusicPromptAssist({ canvasGenerationDialogs, getCanvasGenerationDialogById, updateCanvasGenerationDialogById, - scopeKey, + currentUserId, + projectId, }: { canvasGenerationDialogs: CanvasGenerationDialogState[]; getCanvasGenerationDialogById: ( @@ -92,20 +111,24 @@ export function useImageCanvasBackgroundMusicPromptAssist({ dialogId: string, updater: CanvasGenerationDialogUpdater, ) => void; - scopeKey?: string | null; + currentUserId?: string | null; + projectId?: 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 scopeKey = useMemo( + () => resolveBackgroundMusicPromptScopeKey(currentUserId, projectId), + [currentUserId, projectId], + ); + // 只在已提交的生命周期里推进;render 阶段改写会让被放弃的并发 render 污染 + // 已提交 operation 的迟到响应判断。 + const committedScopeKeyRef = useRef(scopeKey); const [dialogStates, setDialogStates] = useState< Record >({}); - scopeKeyRef.current = scopeKey; - const publishDialogState = useCallback((dialogId: string) => { const state = modelRef.current.getDialogState(dialogId); const nextState: BackgroundMusicPromptAssistDialogState = { @@ -189,6 +212,26 @@ export function useImageCanvasBackgroundMusicPromptAssist({ [publishDialogState, updateDialogPrompt], ); + /** + * 迟到响应被判定为 stale 时,状态模型可能仍把这次 operation 当作当前 operation。 + * 只删 Map 记录会让面板永久停在 `completing` / `simplifying`,所以这里先让模型 + * 完成 reject 再放弃它;输入框保留用户当前文本,不写回任何候选。 + */ + const discardStaleOperation = useCallback( + (operation: BackgroundMusicPromptOperation) => { + if ( + modelRef.current.getDialogState(operation.dialogId).operationId !== + operation.operationId + ) { + return; + } + modelRef.current.rejectOperation(operation); + activeOperationsRef.current.delete(operation.dialogId); + publishDialogState(operation.dialogId); + }, + [publishDialogState], + ); + useEffect(() => { const currentDialogIds = new Set( canvasGenerationDialogs @@ -222,11 +265,19 @@ export function useImageCanvasBackgroundMusicPromptAssist({ getCanvasGenerationDialogById, ]); - useEffect(() => { - if (Object.is(previousScopeKeyRef.current, scopeKey)) { + // 必须是 layout effect:passive effect 排在 commit 之后的独立任务里,新 scope 已经 + // commit 而清理尚未执行的窗口中,迟到的旧账号 / 旧项目响应会通过 operation 与 scope + // 检查,并把候选写进同 ID 的当前 dialog;随后的清理只会 reset 助手状态,不会撤销 + // 已经写入的 Prompt。layout effect 在 commit 内同步执行,promise continuation 无法 + // 插进这段同步代码,因此不存在该窗口;它同样只对已提交的 render 执行,被放弃的并发 + // render 依旧不会推进 scope。 + useLayoutEffect(() => { + if (Object.is(committedScopeKeyRef.current, scopeKey)) { return; } - previousScopeKeyRef.current = scopeKey; + // 账号或项目切换必须原子完成:中止旧请求、清空 operation、reset 状态模型、 + // 清空错误与公开 dialog 状态,最后按新 scope 重建已知 dialog 集合。 + committedScopeKeyRef.current = scopeKey; for (const activeOperation of activeOperationsRef.current.values()) { activeOperation.abortController?.abort(); } @@ -300,7 +351,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({ const abortController = new AbortController(); const operation = start.operation; - const operationScopeKey = scopeKeyRef.current; + const operationScopeKey = committedScopeKeyRef.current; activeOperationsRef.current.set(dialogId, { operation, abortController, @@ -326,8 +377,9 @@ export function useImageCanvasBackgroundMusicPromptAssist({ if ( activeOperation?.operation.operationId !== operation.operationId || !Object.is(activeOperation.scopeKey, operationScopeKey) || - !Object.is(operationScopeKey, scopeKeyRef.current) + !Object.is(operationScopeKey, committedScopeKeyRef.current) ) { + discardStaleOperation(operation); return { started: true, applied: false, @@ -393,8 +445,9 @@ export function useImageCanvasBackgroundMusicPromptAssist({ if ( activeOperation?.operation.operationId !== operation.operationId || !Object.is(activeOperation.scopeKey, operationScopeKey) || - !Object.is(operationScopeKey, scopeKeyRef.current) + !Object.is(operationScopeKey, committedScopeKeyRef.current) ) { + discardStaleOperation(operation); return { started: true, applied: false, @@ -450,6 +503,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({ }, [ clearActiveOperation, + discardStaleOperation, getCanvasGenerationDialogById, isSubmissionLocked, publishDialogState, @@ -550,7 +604,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({ activeOperationsRef.current.set(dialogId, { operation: start.operation, abortController: null, - scopeKey: scopeKeyRef.current, + scopeKey: committedScopeKeyRef.current, }); errorMessagesRef.current.delete(dialogId); publishDialogState(dialogId); @@ -578,7 +632,7 @@ export function useImageCanvasBackgroundMusicPromptAssist({ } const latestDialog = getCanvasGenerationDialogById(operation.dialogId); if ( - !Object.is(activeOperation.scopeKey, scopeKeyRef.current) || + !Object.is(activeOperation.scopeKey, committedScopeKeyRef.current) || !isBackgroundMusicDialog(latestDialog) || latestDialog.composerOpen === false ) { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index acc8f01ea..d14abd1a3 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -683,7 +683,8 @@ export function useImageCanvasGenerationWorkflow({ canvasGenerationDialogs, getCanvasGenerationDialogById, updateCanvasGenerationDialogById, - scopeKey: projectId, + currentUserId, + projectId, }); const previousTaskCountRef = useRef(canvasGenerationDialogs.length); const splittingIconSpritesheetLayerIdsRef = useRef(new Set()); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 4c046db42..cd568284e 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -10,6 +10,7 @@ import { deleteEditorAssetFolder, deleteEditorProject, editEditorImage, + EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, extractEditorUiDesignAssets, generateEditorBackgroundMusic, generateEditorCharacterAnimation, @@ -41,6 +42,10 @@ const editorRetryOptionsExpectation = expect.objectContaining({ retryUnsafeMethods: true, retryableStatusCodes: expect.arrayContaining([429]), }); +// 助手请求只带有界超时:额外的 transport retry 会把一次业务语义轮放大成多次上游调用。 +const promptAssistRequestOptions = { + timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, +}; vi.mock('../apiClient', () => ({ requestJson: requestJsonMock, @@ -1644,8 +1649,11 @@ describe('editorProjectClient', () => { signal: undefined, }, 'AI 补全背景音乐提示词失败', + promptAssistRequestOptions, ); - expect(requestJsonMock.mock.calls[0]).toHaveLength(3); + expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000); + expect(requestJsonMock.mock.calls[0]).toHaveLength(4); + expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions); }); it('simplifies a background music prompt without forwarding client-controlled limits', async () => { @@ -1680,8 +1688,10 @@ describe('editorProjectClient', () => { signal: undefined, }, '简化背景音乐提示词失败', + promptAssistRequestOptions, ); - expect(requestJsonMock.mock.calls[0]).toHaveLength(3); + expect(requestJsonMock.mock.calls[0]).toHaveLength(4); + expect(requestJsonMock.mock.calls[0][3]).toEqual(promptAssistRequestOptions); }); it.each([ @@ -1727,6 +1737,7 @@ describe('editorProjectClient', () => { body: JSON.stringify({ currentPrompt: '森林冒险' }), }), fallbackMessage, + promptAssistRequestOptions, ); }, ); @@ -1764,8 +1775,9 @@ describe('editorProjectClient', () => { body: JSON.stringify({ currentPrompt: '森林冒险' }), }), fallbackMessage, + promptAssistRequestOptions, ); - expect(requestJsonMock.mock.calls[0]).toHaveLength(3); + expect(requestJsonMock.mock.calls[0]).toHaveLength(4); }, ); diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e3838cd8d..dcff0c7dd 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -31,6 +31,11 @@ const EDITOR_BACKGROUND_MUSIC_PROMPT_COMPLETION_API = '/api/editor/audios/background-music/prompts/completions'; const EDITOR_BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_API = '/api/editor/audios/background-music/prompts/simplifications'; +/** + * 覆盖简化两个业务语义轮加单轮 transport retry 的服务端预算,同时为浏览器到 BFF 的 + * 悬挂连接提供有界退出;超时后走既有失败路径恢复 idle,不写回候选。 + */ +export const EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000; const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing'; const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES = 256 * 1024; @@ -1235,6 +1240,9 @@ function requestEditorBackgroundMusicPromptAssist( signal: options.signal, }, fallbackMessage, + { + timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, + }, ); }