fix(create): prevent duplicate rapid submissions
This commit is contained in:
14
web/src/pages/create/creation-submit-gate.ts
Normal file
14
web/src/pages/create/creation-submit-gate.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function createCreationSubmitGate() {
|
||||
let locked = false;
|
||||
|
||||
return {
|
||||
tryAcquire() {
|
||||
if (locked) return false;
|
||||
locked = true;
|
||||
return true;
|
||||
},
|
||||
release() {
|
||||
locked = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
|
||||
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 loadCreationRuntime = () => import("./creation-runtime");
|
||||
@@ -113,6 +114,7 @@ export default function CreatePage() {
|
||||
const taskSyncWarningRef = useRef(false);
|
||||
const activeGenerationTaskIdsRef = useRef(new Set<string>());
|
||||
const retryPreparingRef = useRef(new Set<string>());
|
||||
const submitGateRef = useRef(createCreationSubmitGate());
|
||||
const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null);
|
||||
const [retrySequence, setRetrySequence] = useState(0);
|
||||
const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false);
|
||||
@@ -527,24 +529,33 @@ export default function CreatePage() {
|
||||
const releaseRetryLock = () => {
|
||||
if (retryLockKey) retryPreparingRef.current.delete(retryLockKey);
|
||||
};
|
||||
if (!submitGateRef.current.tryAcquire()) {
|
||||
releaseRetryLock();
|
||||
return;
|
||||
}
|
||||
const releaseSubmitGate = () => submitGateRef.current.release();
|
||||
const text = prompt.trim();
|
||||
if (!text || busy || !activeConversation) {
|
||||
releaseRetryLock();
|
||||
releaseSubmitGate();
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) {
|
||||
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
||||
releaseRetryLock();
|
||||
releaseSubmitGate();
|
||||
return;
|
||||
}
|
||||
if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) {
|
||||
toast.error("当前模型不支持所选视频时长,请重新选择");
|
||||
releaseRetryLock();
|
||||
releaseSubmitGate();
|
||||
return;
|
||||
}
|
||||
if (attachments.length > maxReferences) {
|
||||
toast.warning("参考内容正在按当前模型能力调整,请稍后重试");
|
||||
releaseRetryLock();
|
||||
releaseSubmitGate();
|
||||
return;
|
||||
}
|
||||
const settings = { ratio, seconds, quality, videoQuality, count };
|
||||
@@ -559,7 +570,15 @@ export default function CreatePage() {
|
||||
characterCount: 0,
|
||||
});
|
||||
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>>;
|
||||
try {
|
||||
skillExecution = await runtime.skillRuntime.prepare({
|
||||
@@ -571,6 +590,7 @@ export default function CreatePage() {
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "技能上下文加载失败");
|
||||
releaseRetryLock();
|
||||
releaseSubmitGate();
|
||||
return;
|
||||
}
|
||||
const expandedPrompt = skillExecution.prompt;
|
||||
@@ -719,6 +739,7 @@ export default function CreatePage() {
|
||||
for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId);
|
||||
requestLifecycle.release();
|
||||
releaseRetryLock();
|
||||
releaseSubmitGate();
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
|
||||
14
web/test/creation-submit-gate.test.ts
Normal file
14
web/test/creation-submit-gate.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user