diff --git a/frontend/app/api/launch_kit/route.js b/frontend/app/api/launch_kit/route.js index 464e1205..eb23ce97 100644 --- a/frontend/app/api/launch_kit/route.js +++ b/frontend/app/api/launch_kit/route.js @@ -218,6 +218,7 @@ export async function POST(request) { baseUrl: providerBaseUrl, modelName: providerModelName, allowServerKey: isOwner, + signal: request.signal, }, }); diff --git a/frontend/app/page.js b/frontend/app/page.js index b42f9665..846d7c6e 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -411,6 +411,7 @@ export default function Home() { reddit: { subreddit: "", title: "" }, }); const fileInputRef = useRef(null); + const generationAbortRef = useRef(null); const campaignApplication = useMemo(() => createBrowserCampaignApplication({ getStorage: () => window.localStorage, key: LIBRARY_KEY, @@ -880,7 +881,7 @@ ${extractedText}`); } } - async function requestGeneration(requestedChannels) { + async function requestGeneration(requestedChannels, signal = null) { if (!form.notes.trim() && !form.links.trim() && !form.repo.trim() && documentText.length === 0) { throw new Error("Add a brief, link, repository, or extractable text file before generating."); } @@ -898,6 +899,7 @@ ${extractedText}`); const response = await fetch("/api/launch_kit", { method: "POST", headers: authHeaders({ "Content-Type": "application/json" }), + signal, body: JSON.stringify({ project_name: form.projectName.trim() || "Untitled campaign", notes: form.notes.trim(), @@ -946,11 +948,29 @@ ${extractedText}`); return { accepted, nextGenerationRun, data }; } + function beginGenerationRequest() { + const controller = new AbortController(); + generationAbortRef.current = controller; + return controller; + } + + function finishGenerationRequest(controller) { + if (generationAbortRef.current === controller) generationAbortRef.current = null; + } + + function cancelGeneration() { + const controller = generationAbortRef.current; + if (!controller || controller.signal.aborted) return; + controller.abort(); + setMessage({ type: "warning", text: "Cancelling generation. Existing drafts will remain unchanged." }); + } + async function generateInitialCampaign() { + const controller = beginGenerationRequest(); setBusy(true); setMessage(null); try { - const generation = await requestGeneration(channels); + const generation = await requestGeneration(channels, controller.signal); if (generation.strategyBlocked) { setStrategyReview(generation.data.strategy_review); setStage("destinations"); @@ -979,9 +999,14 @@ ${extractedText}`); : `Campaign generated with ${data.providerUsed || provider.label}. Review and approve each destination before publishing.`, }); } catch (error) { + if (error?.name === "AbortError") { + setMessage({ type: "warning", text: "Generation cancelled before completion. No campaign draft was replaced." }); + return; + } const recovery = providerRecoveryMessage(error.providerError); setMessage({ type: "error", text: [error.message, recovery].filter(Boolean).join(" ") }); } finally { + finishGenerationRequest(controller); setBusy(false); } } @@ -995,10 +1020,11 @@ ${extractedText}`); } setRegenerationDialogOpen(false); + const controller = beginGenerationRequest(); setBusy(true); setMessage(null); try { - const generation = await requestGeneration(targetChannels); + const generation = await requestGeneration(targetChannels, controller.signal); if (generation.strategyBlocked) { setStrategyReview(generation.data.strategy_review); setStage("destinations"); @@ -1035,12 +1061,21 @@ ${extractedText}`); : "The previous campaign version was archived and all selected destinations were regenerated.", }); } catch (error) { + if (error?.name === "AbortError") { + dispatchCampaign({ type: "MARK_CHANNELS_CANCELLED", channels: targetChannels }); + setMessage({ + type: "warning", + text: "Generation cancelled. Existing drafts and edits were preserved; cancelled destinations can be retried.", + }); + return; + } const recovery = providerRecoveryMessage(error.providerError); setMessage({ type: "error", text: [error.message, recovery, "Existing drafts and edits were not changed."].filter(Boolean).join(" "), }); } finally { + finishGenerationRequest(controller); setBusy(false); } } @@ -2056,12 +2091,14 @@ async function exportZip() { {(channelStates[activeChannel]?.issues || []).length > 0 && (
{channelStates[activeChannel]?.status === "failed" ? "Generation failed" - : channelStates[activeChannel]?.status === "needs_review" + : channelStates[activeChannel]?.status === "cancelled" + ? "Generation cancelled" + : channelStates[activeChannel]?.status === "needs_review" ? "Unresolved quality issues" : "Generation notes"} @@ -2100,7 +2137,7 @@ async function exportZip() { onClick={() => void performRegeneration(REGENERATION_POLICIES.CHANNEL, activeChannel)} disabled={busy || !providerReadiness.ready} > - {channelStates[activeChannel]?.status === "failed" ? "Retry destination" : "Regenerate this channel"} + {["failed", "cancelled"].includes(channelStates[activeChannel]?.status) ? "Retry destination" : "Regenerate this channel"} {channelStates[activeChannel]?.edited && generatedPosts[activeChannel] && (
+ {busy && ( + + )} {stage !== "source" && (