fix(create): prevent duplicate rapid submissions

This commit is contained in:
hzw committed 2026-09-18 23:13:32 +08:00
1 parent 9eb2fcd453
commit 878bd35cf4
3 files changed
+50 -1

No files matched your search

@@ -0,0 +1,14 @@
export function createCreationSubmitGate() {
let locked = false;
return {
tryAcquire() {
if (locked) return false;
locked = true;
return true;
},
release() {
locked = false;
},
};
}
+22 -1
View File
@@ -33,6 +33,7 @@ import { defaultCreationMode, modeLabels, type CreationConversation, type Creati
import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations";
import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
import { CreationAgentEntry } from "./creation-agent-entry"; import { CreationAgentEntry } from "./creation-agent-entry";
import { createCreationSubmitGate } from "./creation-submit-gate";
const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal }))); const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal })));
const loadCreationRuntime = () => import("./creation-runtime"); const loadCreationRuntime = () => import("./creation-runtime");
@@ -113,6 +114,7 @@ export default function CreatePage() {
const taskSyncWarningRef = useRef(false); const taskSyncWarningRef = useRef(false);
const activeGenerationTaskIdsRef = useRef(new Set<string>()); const activeGenerationTaskIdsRef = useRef(new Set<string>());
const retryPreparingRef = useRef(new Set<string>()); const retryPreparingRef = useRef(new Set<string>());
const submitGateRef = useRef(createCreationSubmitGate());
const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null); const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null);
const [retrySequence, setRetrySequence] = useState(0); const [retrySequence, setRetrySequence] = useState(0);
const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false); const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false);
@@ -527,24 +529,33 @@ export default function CreatePage() {
const releaseRetryLock = () => { const releaseRetryLock = () => {
if (retryLockKey) retryPreparingRef.current.delete(retryLockKey); if (retryLockKey) retryPreparingRef.current.delete(retryLockKey);
}; };
if (!submitGateRef.current.tryAcquire()) {
releaseRetryLock();
return;
}
const releaseSubmitGate = () => submitGateRef.current.release();
const text = prompt.trim(); const text = prompt.trim();
if (!text || busy || !activeConversation) { if (!text || busy || !activeConversation) {
releaseRetryLock(); releaseRetryLock();
releaseSubmitGate();
return; return;
} }
if (!selectedModel) { if (!selectedModel) {
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`); toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
releaseRetryLock(); releaseRetryLock();
releaseSubmitGate();
return; return;
} }
if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) { if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) {
toast.error("当前模型不支持所选视频时长,请重新选择"); toast.error("当前模型不支持所选视频时长,请重新选择");
releaseRetryLock(); releaseRetryLock();
releaseSubmitGate();
return; return;
} }
if (attachments.length > maxReferences) { if (attachments.length > maxReferences) {
toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); toast.warning("参考内容正在按当前模型能力调整,请稍后重试");
releaseRetryLock(); releaseRetryLock();
releaseSubmitGate();
return; return;
} }
const settings = { ratio, seconds, quality, videoQuality, count }; const settings = { ratio, seconds, quality, videoQuality, count };
@@ -559,7 +570,15 @@ export default function CreatePage() {
characterCount: 0, characterCount: 0,
}); });
const skillReferences = references.flatMap((reference) => (reference.skill ? [reference.skill] : [])); const skillReferences = references.flatMap((reference) => (reference.skill ? [reference.skill] : []));
const runtime = await loadCreationRuntime(); let runtime: CreationRuntime;
try {
runtime = await loadCreationRuntime();
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成运行时加载失败");
releaseRetryLock();
releaseSubmitGate();
return;
}
let skillExecution: Awaited<ReturnType<typeof runtime.skillRuntime.prepare>>; let skillExecution: Awaited<ReturnType<typeof runtime.skillRuntime.prepare>>;
try { try {
skillExecution = await runtime.skillRuntime.prepare({ skillExecution = await runtime.skillRuntime.prepare({
@@ -571,6 +590,7 @@ export default function CreatePage() {
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "技能上下文加载失败"); toast.error(error instanceof Error ? error.message : "技能上下文加载失败");
releaseRetryLock(); releaseRetryLock();
releaseSubmitGate();
return; return;
} }
const expandedPrompt = skillExecution.prompt; const expandedPrompt = skillExecution.prompt;
@@ -719,6 +739,7 @@ export default function CreatePage() {
for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId); for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId);
requestLifecycle.release(); requestLifecycle.release();
releaseRetryLock(); releaseRetryLock();
releaseSubmitGate();
if (abortRef.current === controller) { if (abortRef.current === controller) {
abortRef.current = null; abortRef.current = null;
setBusy(false); setBusy(false);
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test";
import { createCreationSubmitGate } from "../src/pages/create/creation-submit-gate";
test("creation submit gate rejects a second submit until the first submit releases it", () => {
const gate = createCreationSubmitGate();
expect(gate.tryAcquire()).toBe(true);
expect(gate.tryAcquire()).toBe(false);
gate.release();
expect(gate.tryAcquire()).toBe(true);
});