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
116 changes: 89 additions & 27 deletions frontend/app/api/launch_kit/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,72 @@ import { readGenerationRequestBody } from "../../../lib/server/generationRequest

const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]);

function safeGenerationFailure(error) {
if (error instanceof ProviderError) {
const providerError = providerErrorPayload(error);
return {
ok: false,
error: providerError.message,
providerError,
warnings: [providerError.message],
};
}
return {
ok: false,
error: "SignalFlow could not complete campaign generation.",
warnings: ["Campaign generation failed unexpectedly. Retry deliberately or inspect server diagnostics with the correlation context."],
};
}

function streamGeneration({ generationInput, generationConfig, warnings }) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
let closed = false;
const write = (value) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
} catch {
closed = true;
}
};
Promise.resolve().then(async () => {
try {
const result = await generateStudioPackage({
...generationInput,
config: {
...generationConfig,
onProgress: (progress) => write({ type: "progress", progress }),
},
});
const allWarnings = Array.from(new Set([...warnings, ...(result.warnings || [])]));
write({ type: "result", data: { ...result, warnings: allWarnings } });
} catch (error) {
write({ type: "error", data: safeGenerationFailure(error) });
} finally {
if (!closed) {
closed = true;
try {
controller.close();
} catch {
// The browser may have cancelled the stream already.
}
}
}
});
},
});

return new Response(stream, {
status: 200,
headers: {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-store",
},
});
}

export const maxDuration = 60;

export async function POST(request) {
Expand Down Expand Up @@ -214,7 +280,7 @@ export async function POST(request) {
}
void enableAutoCapture;

const result = await generateStudioPackage({
const generationInput = {
projectName,
notes,
audience,
Expand All @@ -228,13 +294,22 @@ export async function POST(request) {
model_name: providerModelName || modelName,
model_endpoint: providerBaseUrl || modelEndpoint,
appUrl,
config: {
apiKey: providerApiKey,
baseUrl: providerBaseUrl,
modelName: providerModelName,
allowServerKey: isOwner,
signal: request.signal,
},
};
const generationConfig = {
apiKey: providerApiKey,
baseUrl: providerBaseUrl,
modelName: providerModelName,
allowServerKey: isOwner,
signal: request.signal,
};

if (request.headers.get("accept")?.includes("application/x-ndjson")) {
return streamGeneration({ generationInput, generationConfig, warnings });
}

const result = await generateStudioPackage({
...generationInput,
config: generationConfig,
});

const allWarnings = Array.from(new Set([...warnings, ...(result.warnings || [])]));
Expand All @@ -243,25 +318,12 @@ export async function POST(request) {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
if (error instanceof ProviderError) {
const providerError = providerErrorPayload(error);
return new Response(JSON.stringify({
ok: false,
error: providerError.message,
providerError,
warnings: [providerError.message],
}), {
status: providerError.httpStatus && providerError.httpStatus >= 400 ? providerError.httpStatus : 502,
headers: { "Content-Type": "application/json" },
});
}

return new Response(JSON.stringify({
ok: false,
error: "SignalFlow could not complete campaign generation.",
warnings: ["Campaign generation failed unexpectedly. Retry deliberately or inspect server diagnostics with the correlation context."],
}), {
status: 500,
const failure = safeGenerationFailure(error);
const status = error instanceof ProviderError
? (error.httpStatus && error.httpStatus >= 400 ? error.httpStatus : 502)
: 500;
return new Response(JSON.stringify(failure), {
status,
headers: { "Content-Type": "application/json" },
});
}
Expand Down
110 changes: 102 additions & 8 deletions frontend/app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,58 @@ async function readJsonResponse(response, fallbackMessage) {
throw new Error(response.ok ? fallbackMessage : `${fallbackMessage} (HTTP ${response.status})`);
}

async function readGenerationResponse(response, onProgress, fallbackMessage) {
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("application/x-ndjson") || !response.body) {
return readJsonResponse(response, fallbackMessage);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalData = null;

const processLine = (line) => {
if (!line.trim()) return;
const event = safeJsonParse(line, null);
if (!event || typeof event !== "object") throw new Error(fallbackMessage);
if (event.type === "progress" && event.progress) {
onProgress?.(event.progress);
return;
}
if ((event.type === "result" || event.type === "error") && event.data) {
finalData = event.data;
}
};

while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) processLine(line);
}
buffer += decoder.decode();
if (buffer.trim()) processLine(buffer);

if (finalData && typeof finalData === "object") return finalData;
throw new Error(fallbackMessage);
}

function generationProgressLabel(status) {
const labels = {
queued: "Queued",
generating: "Generating",
revising: "Revising",
complete: "Complete",
needs_review: "Needs review",
failed: "Failed",
cancelled: "Cancelled",
};
return labels[String(status || "")] || "Preparing";
}

function downloadText(filename, value, type = "text/plain") {
const blob = new Blob([value], { type });
const url = URL.createObjectURL(blob);
Expand Down Expand Up @@ -394,6 +446,7 @@ export default function Home() {
const [files, setFiles] = useState([]);
const [documentText, setDocumentText] = useState([]);
const [busy, setBusy] = useState(false);
const [generationProgress, setGenerationProgress] = useState(null);
const [message, setMessage] = useState(null);
const [strategyReview, setStrategyReview] = useState(null);
const [library, setLibrary] = useState([]);
Expand Down Expand Up @@ -729,6 +782,7 @@ const sourceAndChannelsReady = sourceSignals > 0 && channels.length > 0;
setFiles([]);
setDocumentText([]);
setPublishOptions({ reddit: { subreddit: "", title: "" } });
setGenerationProgress(null);
setMessage(null);
navigateSection("studio");
}
Expand Down Expand Up @@ -899,7 +953,10 @@ ${extractedText}`);
});
const response = await fetch("/api/launch_kit", {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
headers: authHeaders({
"Content-Type": "application/json",
Accept: "application/x-ndjson",
}),
signal,
body: JSON.stringify({
project_name: form.projectName.trim() || "Untitled campaign",
Expand Down Expand Up @@ -930,7 +987,11 @@ ${extractedText}`);
}),
});

const data = await readJsonResponse(response, "SignalFlow returned an unreadable generation response.");
const data = await readGenerationResponse(
response,
setGenerationProgress,
"SignalFlow returned an unreadable generation response.",
);
if (data.code === "strategy_quality_blocked" && data.strategy_review) {
return { strategyBlocked: true, data };
}
Expand All @@ -950,6 +1011,7 @@ ${extractedText}`);
}

function beginGenerationRequest() {
setGenerationProgress(null);
const controller = new AbortController();
generationAbortRef.current = controller;
return controller;
Expand All @@ -963,6 +1025,9 @@ ${extractedText}`);
const controller = generationAbortRef.current;
if (!controller || controller.signal.aborted) return;
controller.abort();
setGenerationProgress((previous) => previous
? { ...previous, phase: "cancelled", status: "cancelled" }
: previous);
setMessage({ type: "warning", text: "Cancelling generation. Existing drafts will remain unchanged." });
}

Expand Down Expand Up @@ -2301,12 +2366,41 @@ async function exportZip() {
</div>

<div className="studio-actionbar" id="campaign-command">
<div className="studio-actionbar__summary">
<span>{sourceSignals} source signal{sourceSignals === 1 ? "" : "s"}</span>
<i />
<span>{channels.length} destinations</span>
<i />
<span>{provider.label}</span>
<div
className={`studio-actionbar__summary ${busy && generationProgress ? "has-progress" : ""}`}
role={busy && generationProgress ? "status" : undefined}
aria-live={busy && generationProgress ? "polite" : undefined}
aria-atomic={busy && generationProgress ? "true" : undefined}
>
{busy && generationProgress ? (
<>
<span>
{generationProgress.phase === "strategy"
? `Strategy · ${generationProgressLabel(generationProgress.strategy)}`
: generationProgress.phase === "cancelled"
? "Generation · Cancelling"
: `Destinations · ${generationProgress.completedDestinations || 0}/${generationProgress.totalDestinations || channels.length} complete`}
</span>
<i />
<span>{provider.label}</span>
<div className="generation-progress-list" aria-label="Destination generation progress">
{Object.entries(generationProgress.destinations || {}).map(([channelId, status]) => (
<span className={`generation-progress-chip is-${status}`} key={channelId}>
<PlatformIcon platform={channelId} size={13} />
{channelMeta(channelId).label} · {generationProgressLabel(status)}
</span>
))}
</div>
</>
) : (
<>
<span>{sourceSignals} source signal{sourceSignals === 1 ? "" : "s"}</span>
<i />
<span>{channels.length} destinations</span>
<i />
<span>{provider.label}</span>
</>
)}
</div>
<div className="studio-actionbar__actions">
{busy && (
Expand Down
44 changes: 44 additions & 0 deletions frontend/app/studio-product.css
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,50 @@
background: var(--app-surface);
}

.app-shell .studio-actionbar__summary.has-progress {
flex: 1 1 32rem;
flex-wrap: wrap;
min-width: 0;
}

.app-shell .generation-progress-list {
flex: 1 1 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
min-width: 0;
}

.app-shell .generation-progress-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
padding: 0.3rem 0.5rem;
border: 1px solid var(--app-line);
border-radius: 999px;
background: var(--app-surface-muted);
color: var(--app-muted);
font-size: 0.68rem;
line-height: 1;
}

.app-shell .generation-progress-chip.is-generating,
.app-shell .generation-progress-chip.is-revising {
color: var(--app-ink);
}

.app-shell .generation-progress-chip.is-complete {
color: var(--app-ink);
background: var(--app-surface);
}

.app-shell .generation-progress-chip.is-failed,
.app-shell .generation-progress-chip.is-cancelled,
.app-shell .generation-progress-chip.is-needs_review {
font-weight: 700;
}

@media (max-width: 68rem) {
.app-shell .studio-page[data-stage="source"],
.app-shell .studio-page[data-stage="destinations"] {
Expand Down
Loading
Loading