音频提交上下文按任务 id 各自留存,重叠提交不再互相覆盖

- `resourceGenerationAudioSubmissionRef` 从单槽改成按任务 id 索引的 Map(键是 task id,值里
  不再重复存 taskId)
- 提交写入改成 `set(task.taskId, …)`,收口读取改成 `get(settlement.taskId)` + `delete`,切项目
  改成 `clear()`
- 原先两条音频提交重叠时,后一次提交会覆盖前一条的提交上下文,前一条「后端从未受理」的即时
  失败因此找不到自己的恢复上下文,只走通用失败提示、丢掉「面板连原草稿带回来」
- 新增宿主用例「两条音频提交重叠:第一条未被受理时,它的面板仍会被带回来」:给测试宿主加
  `holdFirstAssetGenerationStart` 开关,把第一次派发挂在受理前,复现两条提交重叠
This commit is contained in:
2026-09-21 00:17:05 +08:00
parent 7b1bc1d3e8
commit f2a3ba7aca
2 changed files with 79 additions and 14 deletions
@@ -2080,18 +2080,26 @@ export default function ProjectDevelopmentView({
new Map<string, ResourceEditRequestIdentity>(),
);
/**
*
* ** id**
*
* ****稿
* `dispatchedImmediately` 沿
* 使
*
* Map
*
* 稿
*/
const resourceGenerationAudioSubmissionRef = useRef<{
taskId: string;
draftId: string;
kind: ResourceCanvasGenerationKind;
dispatchedImmediately: boolean;
} | null>(null);
const resourceGenerationAudioSubmissionRef = useRef(
new Map<
string,
{
draftId: string;
kind: ResourceCanvasGenerationKind;
dispatchedImmediately: boolean;
}
>(),
);
/** 用户主动收起浮层时留下的可再编辑草稿(按草稿 ID 记,提交成功后清掉)。 */
const resourceGenerationDraftRef = useRef(
new Map<
@@ -7860,12 +7868,11 @@ export default function ProjectDevelopmentView({
idempotencyKey: input.idempotencyKey,
prompt: input.prompt,
});
resourceGenerationAudioSubmissionRef.current = {
taskId: task.taskId,
resourceGenerationAudioSubmissionRef.current.set(task.taskId, {
draftId,
kind: input.kind,
dispatchedImmediately,
};
});
resourceGenerationDraftRef.current.set(draftId, {
kind: input.kind,
prompt: input.prompt,
@@ -7959,9 +7966,11 @@ export default function ProjectDevelopmentView({
// 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。
return;
}
const audioSubmission = resourceGenerationAudioSubmissionRef.current;
if (audioSubmission?.taskId === settlement.taskId) {
resourceGenerationAudioSubmissionRef.current = null;
const audioSubmission = resourceGenerationAudioSubmissionRef.current.get(
settlement.taskId,
);
if (audioSubmission) {
resourceGenerationAudioSubmissionRef.current.delete(settlement.taskId);
if (
settlement.status === 'failed' &&
settlement.record === null &&
@@ -8249,7 +8258,7 @@ export default function ProjectDevelopmentView({
setResourceAssetGenerationPanel(null);
// 收起时的草稿与音频提交身份同样是本项目内的记忆,换项目一并作废。
resourceGenerationAudioRequestRef.current.clear();
resourceGenerationAudioSubmissionRef.current = null;
resourceGenerationAudioSubmissionRef.current.clear();
resourceGenerationDraftRef.current.clear();
resourceAssetGenerationDraftRef.current.clear();
void (async () => {
@@ -102,6 +102,13 @@ function installHostTauri(options: {
* 所以「未受理」与「已受理」两条路能在同一条用例里对照。
*/
assetGenerationStartError?: string;
/**
* 让**第一次** `start_local_project_asset_generation` 挂在半空(受理前)。
*
* 用来复现「两条音频提交重叠」:第一条还在等受理时提交第二条,第二条会排在本地队列里。
* 用例用返回的 `releaseHeldAssetGenerationStart()` 决定第一条什么时候收到结果。
*/
holdFirstAssetGenerationStart?: boolean;
}) {
const layoutWrites: LayoutWrite[] = [];
const deriveCalls: Array<Record<string, unknown>> = [];
@@ -114,6 +121,7 @@ function installHostTauri(options: {
const assetGenerationStartError = {
current: options.assetGenerationStartError,
};
let releaseHeldStart: (() => void) | null = null;
const positions: ProjectResourceCanvasPosition[] = [];
const invoke = vi.fn(
@@ -192,7 +200,13 @@ function installHostTauri(options: {
}
if (command === 'start_local_project_asset_generation') {
const startArgs = args ?? {};
const isFirstStart = assetGenerationStarts.length === 0;
assetGenerationStarts.push(structuredClone(startArgs));
if (options.holdFirstAssetGenerationStart && isFirstStart) {
await new Promise<void>((resolve) => {
releaseHeldStart = resolve;
});
}
if (assetGenerationStartError.current) {
throw new Error(assetGenerationStartError.current);
}
@@ -257,6 +271,7 @@ function installHostTauri(options: {
deriveCalls,
assetGenerationStarts,
assetGenerationStartError,
releaseHeldAssetGenerationStart: () => releaseHeldStart?.(),
unexpectedCommands,
/**
* 后台任务收口为完成:账本里那条记录变成 `completed` + `assetId`,素材同时进清单。
@@ -672,6 +687,47 @@ describe('画布生成入口的宿主生命周期', () => {
await waitFor(() => expect(floatingPanel()).toBeNull());
}, 20_000);
test('两条音频提交重叠:第一条未被受理时,它的面板仍会被带回来', async () => {
const tauri = installHostTauri({
assets: [seedBgmAsset('seed-bgm')],
assetGenerationStartError: '项目权限策略拒绝执行:asset.register',
holdFirstAssetGenerationStart: true,
});
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
await settle();
// 第一条:背景音乐。派发挂在「受理」上,队列还没拿到它的终局。
const { draftId } = await openBgmEntry();
fireEvent.click(panelSubmitButton('生成背景音乐'));
await settle();
expect(tauri.assetGenerationStarts).toHaveLength(1);
// 第二条:音效(另一张占位)。第一条还在途,所以它排在本地队列里,还没有发 IPC。
await openAudioTool('生成音效');
fireEvent.change(
floatingPanel()?.querySelector('textarea') as HTMLTextAreaElement,
{ target: { value: '木门缓慢推开的吱呀声' } },
);
fireEvent.click(panelSubmitButton('生成音效'));
await settle();
expect(tauri.assetGenerationStarts).toHaveLength(1);
/*
放开第一条:它这次从未被受理。提交上下文按**任务 id** 各自留存,所以后来的第二条不许把
第一条的恢复上下文挤掉——面板必须连原草稿回到第一张占位。
*/
await act(async () => {
tauri.releaseHeldAssetGenerationStart();
await Promise.resolve();
});
await waitFor(() => expect(floatingPanel()).not.toBeNull());
expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲');
expect(
placeholderElement(draftId)?.dataset
.resourceCanvasGenerationPlaceholderStatus,
).toBe('failed');
}, 20_000);
test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => {
const tauri = installHostTauri({
assets: [seedBgmAsset('seed-bgm')],