fix(create): prevent duplicate rapid submissions
This commit is contained in:
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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