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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CreateSurveyWithAIOnboarding } from "@/app/(app)/(onboarding)/organizat
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getUserLocale } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { TemplateCreateQueryClientProvider } from "@/modules/survey/components/template-list/query-client-provider";
import { getSurveyAIAvailability } from "@/modules/survey/lib/get-survey-ai-availability";

interface AIOnboardingPageProps {
Expand Down Expand Up @@ -42,7 +43,9 @@ const Page = async (props: AIOnboardingPageProps) => {

return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<CreateSurveyWithAIOnboarding workspaceId={workspace.id} language={locale} />
<TemplateCreateQueryClientProvider>
<CreateSurveyWithAIOnboarding workspaceId={workspace.id} language={locale} />
</TemplateCreateQueryClientProvider>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { PencilLineIcon, SparklesIcon, SquareLibraryIcon } from "lucide-react";
import { PencilLineIcon, SquareLibraryIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import toast from "react-hot-toast";
Expand All @@ -12,6 +12,7 @@ import type { TAIUnavailableReason } from "@/lib/ai/service";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { useCreateSurveyFromTemplate } from "@/modules/survey/components/template-list/hooks/use-create-survey-from-template";
import { getUnavailableMessageKey } from "@/modules/survey/components/template-list/lib/ai-create-utils";
import { AiGlyph } from "@/modules/ui/components/ai";

type TOnboardingSurveyPath = "scratch" | "template" | "ai";

Expand Down Expand Up @@ -66,7 +67,7 @@ export const CreateFirstSurvey = ({
{
title: t("workspace.surveys.ai_create.create_with_ai"),
description: t("organizations.workspaces.new.survey.create_with_ai_description"),
icon: SparklesIcon,
icon: AiGlyph,
disabled: !isAIAvailable,
disabledDescription: aiDisabledDescription,
onClick: () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import { AIOutputTokenLimitError } from "@formbricks/ai";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { V3SurveyGeneratedPayloadValidationError } from "@/app/api/v3/surveys/generate/service";
import { isClientAbort, toStreamErrorEvent } from "./error-events";

describe("toStreamErrorEvent", () => {
test("maps a quota error to ai_quota_exceeded and forwards retryAfter", () => {
const event = toStreamErrorEvent(new TooManyRequestsError("ai_quota_exceeded", 42));

expect(event.code).toBe("ai_quota_exceeded");
expect(event.retryAfter).toBe(42);
});

test("maps the output token limit to ai_output_too_long", () => {
expect(toStreamErrorEvent(new AIOutputTokenLimitError({ maxOutputTokens: 8192 })).code).toBe(
"ai_output_too_long"
);
});

test("maps a payload validation failure to ai_generated_payload_invalid with its params", () => {
const invalidParams = [{ name: "generatedSurvey.name", reason: "Required" }];

const event = toStreamErrorEvent(new V3SurveyGeneratedPayloadValidationError(invalidParams));

expect(event.code).toBe("ai_generated_payload_invalid");
expect(event.invalid_params).toEqual(invalidParams);
});

test("falls back to ai_generation_failed for anything else", () => {
expect(toStreamErrorEvent(new Error("socket hang up")).code).toBe("ai_generation_failed");
});

test("never leaks the caught error's message into detail", () => {
// The detail is rendered to the user, and provider errors routinely echo prompt fragments back.
const leaky = new Error("prompt rejected: 'ask employees about their salary at ACME Corp'");

const event = toStreamErrorEvent(leaky);

expect(event.detail).not.toContain("ACME Corp");
expect(event.detail).not.toContain(leaky.message);
expect(event.detail.length).toBeGreaterThan(0);
});
});

describe("isClientAbort", () => {
const abortedSignal = () => {
const controller = new AbortController();
controller.abort();
return controller.signal;
};

test("treats any failure on an aborted request as a client abort", () => {
expect(isClientAbort(new Error("stream closed"), abortedSignal())).toBe(true);
});

test("recognises an AbortError even when the signal has not settled", () => {
const error = new Error("The operation was aborted");
error.name = "AbortError";

expect(isClientAbort(error, new AbortController().signal)).toBe(true);
});

test("does not swallow a real generation failure", () => {
expect(
isClientAbort(new TooManyRequestsError("ai_quota_exceeded", 30), new AbortController().signal)
).toBe(false);
});
});
76 changes: 76 additions & 0 deletions apps/web/app/api/internal/surveys/generate/lib/error-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { AIOutputTokenLimitError } from "@formbricks/ai";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { V3SurveyGeneratedPayloadValidationError } from "@/app/api/v3/surveys/generate/service";
import { SURVEY_GENERATION_STREAM_ERROR_CODES, type TSurveyGenerationStreamEvent } from "./events";

/**
* Fixed detail strings, one per code.
*
* Never interpolate the caught error's message: an in-band error event is rendered to the user, and
* provider errors routinely echo fragments of the prompt back in their message.
*/
const STREAM_ERROR_DETAILS = {
[SURVEY_GENERATION_STREAM_ERROR_CODES.QUOTA_EXCEEDED]:
"The AI provider is temporarily rate-limited. Try again shortly.",
[SURVEY_GENERATION_STREAM_ERROR_CODES.OUTPUT_TOO_LONG]:
"The generated survey exceeded the AI output token limit. Simplify the prompt or split it into smaller surveys.",
[SURVEY_GENERATION_STREAM_ERROR_CODES.PAYLOAD_INVALID]:
"The generated survey draft could not be validated.",
[SURVEY_GENERATION_STREAM_ERROR_CODES.GENERATION_FAILED]:
"The AI provider could not finish the survey draft. Try again or add more detail.",
} as const;

/**
* Whether a failure is the client hanging up rather than a generation problem.
*
* Checked *before* classification, and the signal of record is the request signal rather than the
* error: on abort the AI SDK rejects with a DOMException whose shape varies by runtime, while
* `signal.aborted` is unambiguous. Getting this order wrong logs every user pressing Stop as a
* generation failure.
*/
export function isClientAbort(error: unknown, signal: AbortSignal): boolean {
if (signal.aborted) return true;

return error instanceof Error && error.name === "AbortError";
}

/**
* Map a mid-generation failure to the in-band event the client renders.
*
* Only failures that can happen *after* the response body has opened belong here — entitlement,
* auth, rate limiting and body validation are all guarded before the first byte and answer with a
* proper RFC 9457 problem response instead.
*/
export function toStreamErrorEvent(error: unknown): Extract<TSurveyGenerationStreamEvent, { type: "error" }> {
if (error instanceof TooManyRequestsError) {
return {
type: "error",
code: SURVEY_GENERATION_STREAM_ERROR_CODES.QUOTA_EXCEEDED,
detail: STREAM_ERROR_DETAILS[SURVEY_GENERATION_STREAM_ERROR_CODES.QUOTA_EXCEEDED],
retryAfter: error.retryAfter,
};
}

if (error instanceof AIOutputTokenLimitError) {
return {
type: "error",
code: SURVEY_GENERATION_STREAM_ERROR_CODES.OUTPUT_TOO_LONG,
detail: STREAM_ERROR_DETAILS[SURVEY_GENERATION_STREAM_ERROR_CODES.OUTPUT_TOO_LONG],
};
}

if (error instanceof V3SurveyGeneratedPayloadValidationError) {
return {
type: "error",
code: SURVEY_GENERATION_STREAM_ERROR_CODES.PAYLOAD_INVALID,
detail: STREAM_ERROR_DETAILS[SURVEY_GENERATION_STREAM_ERROR_CODES.PAYLOAD_INVALID],
invalid_params: error.invalidParams,
};
}

return {
type: "error",
code: SURVEY_GENERATION_STREAM_ERROR_CODES.GENERATION_FAILED,
detail: STREAM_ERROR_DETAILS[SURVEY_GENERATION_STREAM_ERROR_CODES.GENERATION_FAILED],
};
}
91 changes: 91 additions & 0 deletions apps/web/app/api/internal/surveys/generate/lib/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "vitest";
import {
SURVEY_GENERATION_SNAPSHOT_THROTTLE_MS,
type TSurveyGenerationStreamEvent,
encodeStreamEvent,
shouldEmitSnapshot,
} from "./events";

const decoder = new TextDecoder();
const decode = (event: TSurveyGenerationStreamEvent) => decoder.decode(encodeStreamEvent(event));

describe("encodeStreamEvent", () => {
test("appends exactly one trailing newline", () => {
const encoded = decode({ type: "start", requestId: "req_1" });

expect(encoded.endsWith("\n")).toBe(true);
expect(encoded.slice(0, -1)).not.toContain("\n");
});

test("round-trips every event variant through a newline split", () => {
const events: TSurveyGenerationStreamEvent[] = [
{ type: "start", requestId: "req_1" },
{ type: "partial", seq: 3, draft: { name: "Onboarding" } },
{
type: "done",
language: "en",
payload: { name: "Onboarding" } as never,
validation: { valid: true, invalid_params: [], languages: [] },
},
{ type: "error", code: "ai_quota_exceeded", detail: "Rate-limited.", retryAfter: 30 },
];

const body = events.map((event) => decode(event)).join("");
const parsed = body
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line));

expect(parsed).toEqual(events);
});

test("model text containing newlines cannot break framing", () => {
// The single assumption NDJSON rests on. JSON.stringify escapes these inside strings, so a
// headline the model wrote with a line break stays one frame instead of splitting into two.
const draft = { name: "Line one\nLine two\r\nLine three
Line four" };

const encoded = decode({ type: "partial", seq: 1, draft });
const lines = encoded.split("\n").filter((line) => line.length > 0);

expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0])).toEqual({ type: "partial", seq: 1, draft });
});
});

describe("shouldEmitSnapshot", () => {
const serialized = '{"name":"Onboarding"}';

test("emits the first snapshot immediately", () => {
expect(shouldEmitSnapshot({ now: 1_000, lastEmittedAt: null, serialized, lastSerialized: null })).toBe(
true
);
});

test("suppresses a snapshot inside the throttle window", () => {
expect(
shouldEmitSnapshot({
now: 1_010,
lastEmittedAt: 1_000,
serialized,
lastSerialized: '{"name":"Onboard"}',
})
).toBe(false);
});

test("emits once the throttle window has elapsed", () => {
expect(
shouldEmitSnapshot({
now: 1_000 + SURVEY_GENERATION_SNAPSHOT_THROTTLE_MS,
lastEmittedAt: 1_000,
serialized,
lastSerialized: '{"name":"Onboard"}',
})
).toBe(true);
});

test("suppresses a byte-identical snapshot however long the gap", () => {
expect(
shouldEmitSnapshot({ now: 99_000, lastEmittedAt: 1_000, serialized, lastSerialized: serialized })
).toBe(false);
});
});
89 changes: 89 additions & 0 deletions apps/web/app/api/internal/surveys/generate/lib/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { z } from "zod";
import type { DeepPartial } from "@formbricks/ai";
import type { InvalidParam } from "@/app/api/v3/lib/response";
import type { ZGeneratedSurveyDraftForAI } from "@/app/api/v3/surveys/generate/schemas";
import type { TV3SurveyGenerateValidation } from "@/app/api/v3/surveys/generate/service";
import type { TV3CreateSurveyBody } from "@/app/api/v3/surveys/schemas";

/**
* Snapshot of a draft mid-generation. Whole-object, not a delta, and **unvalidated** — the AI SDK
* runs no schema check on partials, so a headline can be half-written and a range can hold a value
* that is not yet a legal enum member. Display-only; the review step reads the `done` payload.
*/
export type TSurveyGenerationDraftSnapshot = DeepPartial<z.infer<typeof ZGeneratedSurveyDraftForAI>>;

/** Codes that can only be raised mid-stream. Everything else is a pre-stream problem+json. */
export const SURVEY_GENERATION_STREAM_ERROR_CODES = {
QUOTA_EXCEEDED: "ai_quota_exceeded",
OUTPUT_TOO_LONG: "ai_output_too_long",
PAYLOAD_INVALID: "ai_generated_payload_invalid",
GENERATION_FAILED: "ai_generation_failed",
} as const;

export type TSurveyGenerationStreamErrorCode =
(typeof SURVEY_GENERATION_STREAM_ERROR_CODES)[keyof typeof SURVEY_GENERATION_STREAM_ERROR_CODES];

export type TSurveyGenerationStreamEvent =
/**
* Emitted before the model is reached. Next only flushes response headers on the first chunk, so
* without this the client's `fetch()` would not resolve until the first token — and a stream that
* silently buffers would be indistinguishable from a slow model.
*/
| { type: "start"; requestId: string }
| { type: "partial"; seq: number; draft: TSurveyGenerationDraftSnapshot }
/** Mirrors the public endpoint's result shape, so the client reuses its existing create path. */
| {
type: "done";
language: string;
payload: TV3CreateSurveyBody;
validation: TV3SurveyGenerateValidation;
}
| {
type: "error";
code: TSurveyGenerationStreamErrorCode;
detail: string;
invalid_params?: InvalidParam[];
retryAfter?: number;
};

export const SURVEY_GENERATION_STREAM_CONTENT_TYPE = "application/x-ndjson; charset=utf-8";

/** Minimum gap between partial snapshots. ~10fps reads as live without the per-token flood. */
export const SURVEY_GENERATION_SNAPSHOT_THROTTLE_MS = 100;

const encoder = new TextEncoder();

/**
* NDJSON framing: one JSON object, one trailing newline, nothing else. Framing is safe for any
* model output because `JSON.stringify` escapes newlines inside strings — the single assumption
* this protocol rests on, and the one `events.test.ts` asserts directly.
*/
export function encodeStreamEvent(event: TSurveyGenerationStreamEvent): Uint8Array {
return encoder.encode(`${JSON.stringify(event)}\n`);
}

/**
* Whether a partial snapshot is worth putting on the wire.
*
* `partialOutputStream` emits a whole-object snapshot per token, so relaying every one is O(n²) on
* the wire — roughly 8MB for a 4KB draft. Throttling by time keeps it live-looking; dropping
* byte-identical repeats absorbs the tail of a model stall for free.
*
* Pure so the policy is testable without a stream.
*/
export function shouldEmitSnapshot({
now,
lastEmittedAt,
serialized,
lastSerialized,
}: {
now: number;
lastEmittedAt: number | null;
serialized: string;
lastSerialized: string | null;
}): boolean {
if (serialized === lastSerialized) return false;
if (lastEmittedAt === null) return true;

return now - lastEmittedAt >= SURVEY_GENERATION_SNAPSHOT_THROTTLE_MS;
}
Loading
Loading