Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/app/api/launch_kit/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export async function POST(request) {
baseUrl: providerBaseUrl,
modelName: providerModelName,
allowServerKey: isOwner,
signal: request.signal,
},
});

Expand Down
58 changes: 52 additions & 6 deletions frontend/app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.");
}
Expand All @@ -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(),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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");
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -2056,12 +2091,14 @@ async function exportZip() {
{(channelStates[activeChannel]?.issues || []).length > 0 && (
<div
className="draft-quality-issues"
role={["needs_review", "failed"].includes(channelStates[activeChannel]?.status) ? "alert" : "status"}
role={["needs_review", "failed", "cancelled"].includes(channelStates[activeChannel]?.status) ? "alert" : "status"}
>
<strong>
{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"}
</strong>
Expand Down Expand Up @@ -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"}
</button>
{channelStates[activeChannel]?.edited && generatedPosts[activeChannel] && (
<button
Expand Down Expand Up @@ -2271,6 +2308,15 @@ async function exportZip() {
<span>{provider.label}</span>
</div>
<div className="studio-actionbar__actions">
{busy && (
<button
type="button"
className="button button--outline"
onClick={cancelGeneration}
>
Cancel generation
</button>
)}
{stage !== "source" && (
<button
type="button"
Expand Down
18 changes: 16 additions & 2 deletions frontend/lib/ai/generateStudioPackage.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,15 @@ export async function generateStudioPackage(inputs) {
provider: generator,
modelOverride,
config,
}), config.destinationConcurrency);
}), config.destinationConcurrency, { signal: config.signal });

if (config.signal?.aborted) {
const cancelled = new Error("Generation request was cancelled.");
cancelled.code = "provider_request_cancelled";
cancelled.status = 499;
throw cancelled;
}
generatedDestinations = generatedDestinations.filter(Boolean);

const generatedDraftMap = Object.fromEntries(
generatedDestinations
Expand All @@ -482,7 +490,13 @@ export async function generateStudioPackage(inputs) {
provider: generator,
modelOverride,
config,
}), config.destinationConcurrency);
}), config.destinationConcurrency, { signal: config.signal });
if (config.signal?.aborted) {
const cancelled = new Error("Generation request was cancelled.");
cancelled.code = "provider_request_cancelled";
cancelled.status = 499;
throw cancelled;
}
const replacements = new Map(revised.filter(Boolean).map((item) => [item.channel, item]));
generatedDestinations = generatedDestinations.map((item) => replacements.get(item.channel) || item);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/lib/ai/generationConcurrency.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const DEFAULT_DESTINATION_CONCURRENCY = 2;

export async function mapWithConcurrency(items, worker, concurrency = DEFAULT_DESTINATION_CONCURRENCY) {
export async function mapWithConcurrency(items, worker, concurrency = DEFAULT_DESTINATION_CONCURRENCY, { signal = null } = {}) {
const values = Array.isArray(items) ? items : [];
if (!values.length) return [];
if (typeof worker !== "function") throw new TypeError("mapWithConcurrency requires a worker function.");
Expand All @@ -14,6 +14,7 @@ export async function mapWithConcurrency(items, worker, concurrency = DEFAULT_DE

async function runLane() {
while (cursor < values.length) {
if (signal?.aborted) break;
const index = cursor;
cursor += 1;
results[index] = await worker(values[index], index);
Expand Down
5 changes: 5 additions & 0 deletions frontend/lib/ai/providerErrors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const SAFE_CODES = Object.freeze({
QUOTA_EXCEEDED: "provider_quota_exceeded",
PAYMENT_REQUIRED: "provider_payment_required",
TIMEOUT: "provider_timeout",
CANCELLED: "provider_request_cancelled",
UNAVAILABLE: "provider_unavailable",
MALFORMED_RESPONSE: "provider_malformed_response",
EMPTY_RESPONSE: "provider_empty_response",
Expand Down Expand Up @@ -35,6 +36,9 @@ function classify(error) {
if (error?.code === "provider_empty_response") {
return { code: SAFE_CODES.EMPTY_RESPONSE, retryable: true, action: "retry_destination" };
}
if (error?.code === "provider_request_cancelled") {
return { code: SAFE_CODES.CANCELLED, retryable: true, action: "retry_destination" };
}
if (error?.name === "AbortError" || /timed?\s*out|timeout/.test(message)) {
return { code: SAFE_CODES.TIMEOUT, retryable: true, action: "retry_destination" };
}
Expand Down Expand Up @@ -81,6 +85,7 @@ function safeMessage(code, provider) {
case SAFE_CODES.QUOTA_EXCEEDED: return `${label} reports that the current quota is exhausted.`;
case SAFE_CODES.PAYMENT_REQUIRED: return `${label} requires billing or credits before generation can continue.`;
case SAFE_CODES.TIMEOUT: return `${label} did not respond within the request limit.`;
case SAFE_CODES.CANCELLED: return "Generation was cancelled before the provider request completed.";
case SAFE_CODES.UNAVAILABLE: return `${label} is temporarily unavailable.`;
case SAFE_CODES.MALFORMED_RESPONSE: return `${label} returned a response that could not be validated as the required JSON contract.`;
case SAFE_CODES.EMPTY_RESPONSE: return `${label} returned no usable model output.`;
Expand Down
9 changes: 5 additions & 4 deletions frontend/lib/ai/providers/claude.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PROVIDERS, getProviderApiKey } from "../types";
import { createLinkedAbort, cancelledProviderRequestError } from "../requestAbort.mjs";

/**
* Calls Anthropic Claude completions API.
Expand Down Expand Up @@ -30,24 +31,24 @@ export async function generateClaude(prompt, modelOverride = null, config = {})
temperature: 0.2
};

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 50000);
const abort = createLinkedAbort({ signal: config.signal, timeoutMs: 50_000 });

let resp;
try {
resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: controller.signal
signal: abort.signal
});
} catch (err) {
if (err.name === "AbortError") {
if (abort.cancelled()) throw cancelledProviderRequestError();
throw new Error("Request to Anthropic Claude API timed out after 50 seconds.");
}
throw err;
} finally {
clearTimeout(timeoutId);
abort.cleanup();
}

if (!resp.ok) {
Expand Down
9 changes: 5 additions & 4 deletions frontend/lib/ai/providers/customOpenAI.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PROVIDERS, getProviderApiKey } from "../types";
import { createLinkedAbort, cancelledProviderRequestError } from "../requestAbort.mjs";

/**
* Calls a custom user-configured OpenAI-compatible completions gateway.
Expand Down Expand Up @@ -36,24 +37,24 @@ export async function generateCustomOpenAI(prompt, modelOverride = null, config
temperature: 0.2
};

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 50000);
const abort = createLinkedAbort({ signal: config.signal, timeoutMs: 50_000 });

let resp;
try {
resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: controller.signal
signal: abort.signal
});
} catch (err) {
if (err.name === "AbortError") {
if (abort.cancelled()) throw cancelledProviderRequestError();
throw new Error("Request to custom OpenAI API timed out after 50 seconds.");
}
throw err;
} finally {
clearTimeout(timeoutId);
abort.cleanup();
}

if (!resp.ok) {
Expand Down
15 changes: 8 additions & 7 deletions frontend/lib/ai/providers/gemini.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PROVIDERS, getProviderApiKey } from "../types";
import { createLinkedAbort, cancelledProviderRequestError } from "../requestAbort.mjs";

/**
* Calls Google Gemini REST API.
Expand All @@ -13,7 +14,7 @@ export async function generateGemini(prompt, modelOverride = null, config = {})

// Attempt generating JSON response first
try {
return await makeGeminiRequest(prompt, model, apiKey, true, config.maxTokens);
return await makeGeminiRequest(prompt, model, apiKey, true, config.maxTokens, config.signal);
} catch (jsonErr) {
const errorMsg = jsonErr.message || "";
// If the error message suggests responseMimeType is not supported, or it is a 400 parameter error, retry in text mode
Expand All @@ -25,7 +26,7 @@ export async function generateGemini(prompt, modelOverride = null, config = {})
errorMsg.includes("400")
) {
try {
return await makeGeminiRequest(prompt, model, apiKey, false, config.maxTokens);
return await makeGeminiRequest(prompt, model, apiKey, false, config.maxTokens, config.signal);
} catch (textErr) {
throw new Error(`Gemini request failed: ${textErr.message}`);
}
Expand All @@ -34,7 +35,7 @@ export async function generateGemini(prompt, modelOverride = null, config = {})
}
}

async function makeGeminiRequest(prompt, model, apiKey, useJsonMode, maxTokens) {
async function makeGeminiRequest(prompt, model, apiKey, useJsonMode, maxTokens, signal = null) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;

const body = {
Expand All @@ -57,8 +58,7 @@ async function makeGeminiRequest(prompt, model, apiKey, useJsonMode, maxTokens)
body.generationConfig.responseMimeType = "application/json";
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 50000);
const abort = createLinkedAbort({ signal, timeoutMs: 50_000 });

let resp;
try {
Expand All @@ -68,15 +68,16 @@ async function makeGeminiRequest(prompt, model, apiKey, useJsonMode, maxTokens)
"Content-Type": "application/json"
},
body: JSON.stringify(body),
signal: controller.signal
signal: abort.signal
});
} catch (err) {
if (err.name === "AbortError") {
if (abort.cancelled()) throw cancelledProviderRequestError();
throw new Error("Request to Gemini API timed out after 50 seconds.");
}
throw err;
} finally {
clearTimeout(timeoutId);
abort.cleanup();
}

if (!resp.ok) {
Expand Down
9 changes: 5 additions & 4 deletions frontend/lib/ai/providers/groq.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PROVIDERS, getProviderApiKey } from "../types";
import { createLinkedAbort, cancelledProviderRequestError } from "../requestAbort.mjs";

/**
* Calls Groq Cloud chat completion endpoint.
Expand Down Expand Up @@ -27,8 +28,7 @@ export async function generateGroq(prompt, modelOverride = null, config = {}) {
temperature: 0.2
};

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 50000);
const abort = createLinkedAbort({ signal: config.signal, timeoutMs: 50_000 });

let resp;
try {
Expand All @@ -39,15 +39,16 @@ export async function generateGroq(prompt, modelOverride = null, config = {}) {
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify(body),
signal: controller.signal
signal: abort.signal
});
} catch (err) {
if (err.name === "AbortError") {
if (abort.cancelled()) throw cancelledProviderRequestError();
throw new Error("Request to Groq API timed out after 50 seconds.");
}
throw err;
} finally {
clearTimeout(timeoutId);
abort.cleanup();
}

if (!resp.ok) {
Expand Down
Loading
Loading