修复:加固BGM助手前端生命周期
- Prompt 助手 scope 改为账号与项目联合绑定:hook 接收 currentUserId / projectId 两个原始值,内部生成稳定 scope key,调用方无法再传入按引用比较的对象。 - 删除 render 阶段的 scope ref 改写,scope 身份只在已提交的 React 生命周期中推进,被放弃的并发 render 不再污染迟到响应判断。 - scope 切换改用 useLayoutEffect 在 commit 阶段同步原子清理:中止旧请求、清空 active operation、reset 状态模型、清空错误与公开 dialog 状态、重建已知 dialog 集合。修复新 scope 已 commit 而 passive effect 未执行时,旧账号 / 旧项目候选被写入同 ID dialog 的竞态。 - 迟到响应判定为 stale 时先让状态模型完成 reject 再移除 active operation,不再残留 completing / simplifying 忙状态。 - Prompt 助手 client 增加 EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS = 180_000 并透传 timeoutMs,仍不启用前端重试;超时按现有失败路径恢复 idle,保留 canonical Prompt,不产生撤销快照。 - 补充定向测试:账号切换中止与清理、scope 清理早于任何 passive effect 的相位回归、等值 scope props 不误判 stale、StrictMode 双渲染、浏览器超时恢复,以及两个助手路由的超时透传与无重试断言。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T> = {
|
||||
|
||||
type HarnessProps = {
|
||||
initialDialogs: CanvasGenerationDialogState[];
|
||||
scopeKey?: string | null;
|
||||
currentUserId?: string | null;
|
||||
projectId?: string | null;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
@@ -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<PromptAssistResponse>();
|
||||
const newScopeRequest = createDeferred<PromptAssistResponse>();
|
||||
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<PromptAssistResponse>();
|
||||
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<typeof result.current.promptAssist.completePrompt>;
|
||||
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<PromptAssistResponse>();
|
||||
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 }) => (
|
||||
<>
|
||||
<ScopePhaseProbe record={(phase) => phases.push(phase)} />
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
|
||||
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<PromptAssistResponse>();
|
||||
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<typeof result.current.promptAssist.completePrompt>;
|
||||
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<PromptAssistResponse>();
|
||||
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePromptAssistHarness({
|
||||
initialDialogs: [createDialog('dialog-a', '森林冒险')],
|
||||
currentUserId: 'user-a',
|
||||
projectId: 'project-a',
|
||||
}),
|
||||
{ wrapper: StrictMode },
|
||||
);
|
||||
|
||||
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
|
||||
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<PromptAssistResponse>();
|
||||
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
|
||||
const { result } = renderHook(() =>
|
||||
usePromptAssistHarness({
|
||||
initialDialogs: [createDialog('dialog-a', '
森林冒险 ')],
|
||||
currentUserId: 'user-a',
|
||||
projectId: 'project-a',
|
||||
}),
|
||||
);
|
||||
|
||||
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
|
||||
act(() => {
|
||||
action = result.current.promptAssist.completePrompt('dialog-a');
|
||||
});
|
||||
|
||||
const timeoutError = Object.assign(new Error('请求超时:180000ms'), {
|
||||
name: 'TimeoutError',
|
||||
});
|
||||
let outcome!: Awaited<typeof action>;
|
||||
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<PromptAssistResponse>();
|
||||
let signal: AbortSignal | undefined;
|
||||
|
||||
@@ -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<string, ActivePromptOperation>());
|
||||
const errorMessagesRef = useRef(new Map<string, string>());
|
||||
const knownDialogIdsRef = useRef(new Set<string>());
|
||||
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<string, BackgroundMusicPromptAssistDialogState>
|
||||
>({});
|
||||
|
||||
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
|
||||
) {
|
||||
|
||||
@@ -683,7 +683,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
canvasGenerationDialogs,
|
||||
getCanvasGenerationDialogById,
|
||||
updateCanvasGenerationDialogById,
|
||||
scopeKey: projectId,
|
||||
currentUserId,
|
||||
projectId,
|
||||
});
|
||||
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
|
||||
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user