From 22a5b6d974b64582c4d40459e0aea78b937626f0 Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Thu, 3 Sep 2026 05:04:57 +0000 Subject: [PATCH 1/9] fix: fall back to database for Redis session errors (#9125) --- .../lib/__mocks__/secondary-storage.mock.ts | 37 +++++++++ .../lib/better-auth-session-fallback.test.ts | 75 +++++++++++++++++++ patches/better-auth@1.7.0.patch | 19 +++++ pnpm-lock.yaml | 11 +-- pnpm-workspace.yaml | 3 + 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 apps/web/modules/auth/lib/__mocks__/secondary-storage.mock.ts create mode 100644 apps/web/modules/auth/lib/better-auth-session-fallback.test.ts create mode 100644 patches/better-auth@1.7.0.patch diff --git a/apps/web/modules/auth/lib/__mocks__/secondary-storage.mock.ts b/apps/web/modules/auth/lib/__mocks__/secondary-storage.mock.ts new file mode 100644 index 000000000000..6af8612efa80 --- /dev/null +++ b/apps/web/modules/auth/lib/__mocks__/secondary-storage.mock.ts @@ -0,0 +1,37 @@ +import type { BetterAuthOptions } from "better-auth"; +import { vi } from "vitest"; + +export const REDIS_ERROR = new Error("Socket closed unexpectedly"); + +export const createSecondaryStorageMock = () => { + const values = new Map(); + let failReads = false; + + return { + storage: { + get: vi.fn(async (key: string) => { + if (failReads) throw REDIS_ERROR; + return values.get(key) ?? null; + }), + getAndDelete: vi.fn(async (key: string) => { + const value = values.get(key) ?? null; + values.delete(key); + return value; + }), + increment: vi.fn(async (key: string) => { + const value = Number(values.get(key) ?? 0) + 1; + values.set(key, String(value)); + return value; + }), + set: vi.fn(async (key: string, value: string) => { + values.set(key, value); + }), + delete: vi.fn(async (key: string) => { + values.delete(key); + }), + } satisfies BetterAuthOptions["secondaryStorage"], + failReads: () => { + failReads = true; + }, + }; +}; diff --git a/apps/web/modules/auth/lib/better-auth-session-fallback.test.ts b/apps/web/modules/auth/lib/better-auth-session-fallback.test.ts new file mode 100644 index 000000000000..9f3c095de66e --- /dev/null +++ b/apps/web/modules/auth/lib/better-auth-session-fallback.test.ts @@ -0,0 +1,75 @@ +import { REDIS_ERROR, createSecondaryStorageMock } from "./__mocks__/secondary-storage.mock"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { describe, expect, test, vi } from "vitest"; + +const BASE_URL = "https://app.formbricks.test"; + +const createAuthInstance = (storeSessionInDatabase: boolean, preserveSessionInDatabase = false) => { + const secondaryStorage = createSecondaryStorageMock(); + const log = vi.fn(); + const auth = betterAuth({ + baseURL: BASE_URL, + secret: "better-auth-session-fallback-test-secret", + database: memoryAdapter({ user: [], session: [], account: [], verification: [] }), + emailAndPassword: { enabled: true }, + rateLimit: { enabled: false }, + secondaryStorage: secondaryStorage.storage, + session: { storeSessionInDatabase, preserveSessionInDatabase }, + logger: { level: "error", log }, + }); + + const signUp = async (): Promise => { + const response = await auth.api.signUpEmail({ + body: { email: "redis-fallback@example.com", password: "Correct-Horse1", name: "Redis Fallback" }, + asResponse: true, + }); + expect(response.status).toBe(200); + + return response.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "); + }; + + return { auth, secondaryStorage, log, signUp }; +}; + +describe("Better Auth session database fallback", () => { + test("returns the database-backed session when secondary storage throws", async () => { + const { auth, secondaryStorage, log, signUp } = createAuthInstance(true); + const cookie = await signUp(); + secondaryStorage.failReads(); + + await expect(auth.api.getSession({ headers: new Headers({ cookie }) })).resolves.toMatchObject({ + user: { email: "redis-fallback@example.com" }, + }); + expect(log).toHaveBeenCalledWith( + "error", + "Failed to read session from secondary storage; falling back to database", + REDIS_ERROR + ); + }); + + test("propagates the secondary-storage error when sessions are not stored in the database", async () => { + const { auth, secondaryStorage, signUp } = createAuthInstance(false); + const cookie = await signUp(); + secondaryStorage.failReads(); + + await expect(auth.api.getSession({ headers: new Headers({ cookie }) })).rejects.toMatchObject({ + status: "INTERNAL_SERVER_ERROR", + body: { code: "FAILED_TO_GET_SESSION" }, + }); + }); + + test("propagates the secondary-storage error when database sessions are preserved", async () => { + const { auth, secondaryStorage, signUp } = createAuthInstance(true, true); + const cookie = await signUp(); + secondaryStorage.failReads(); + + await expect(auth.api.getSession({ headers: new Headers({ cookie }) })).rejects.toMatchObject({ + status: "INTERNAL_SERVER_ERROR", + body: { code: "FAILED_TO_GET_SESSION" }, + }); + }); +}); diff --git a/patches/better-auth@1.7.0.patch b/patches/better-auth@1.7.0.patch new file mode 100644 index 000000000000..e7dbc699158b --- /dev/null +++ b/patches/better-auth@1.7.0.patch @@ -0,0 +1,19 @@ +diff --git a/dist/db/internal-adapter.mjs b/dist/db/internal-adapter.mjs +index 0f88105d21dea1c2cc9a8f1ab268faede029b47a..c4a61e12810e17d3b2444252448e75155468294e 100644 +--- a/dist/db/internal-adapter.mjs ++++ b/dist/db/internal-adapter.mjs +@@ -321,7 +321,13 @@ const createInternalAdapter = (adapter, ctx) => { + }, + findSession: async (token) => { + if (secondaryStorage) { +- const sessionStringified = await secondaryStorage.get(token); ++ let sessionStringified; ++ try { ++ sessionStringified = await secondaryStorage.get(token); ++ } catch (error) { ++ if (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase) throw error; ++ logger.error("Failed to read session from secondary storage; falling back to database", error); ++ } + if (!sessionStringified && (!options.session?.storeSessionInDatabase || ctx.options.session?.preserveSessionInDatabase)) return null; + if (sessionStringified) { + const s = safeJSONParse(sessionStringified); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 517996530500..6ed7a0142e51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,7 @@ overrides: patchedDependencies: '@better-auth/oauth-provider@1.7.0': c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4 + better-auth@1.7.0: 1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e importers: @@ -355,7 +356,7 @@ importers: version: 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/oauth-provider': specifier: 1.7.0 - version: 1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(a7d5e9ad522ca47a6d8a16da19a4e45c) + version: 1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(ddb3752cb5b0f8e8c6d96ce106aeec89) '@better-auth/utils': specifier: 0.4.2 version: 0.4.2 @@ -601,7 +602,7 @@ importers: version: 3.0.3 better-auth: specifier: 1.7.0 - version: 1.7.0(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + version: 1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) boring-avatars: specifier: 2.0.4 version: 2.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14086,12 +14087,12 @@ snapshots: optionalDependencies: mongodb: 7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7) - '@better-auth/oauth-provider@1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(a7d5e9ad522ca47a6d8a16da19a4e45c)': + '@better-auth/oauth-provider@1.7.0(patch_hash=c902485a86bcc6e01946ff9b5339866f3dcd2bbf84369e1c10cc4608e8e2ebd4)(ddb3752cb5b0f8e8c6d96ce106aeec89)': dependencies: '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.0(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) + better-auth: 1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6) better-call: 1.4.0(zod@4.3.6) jose: 6.2.9 zod: 4.3.6 @@ -19775,7 +19776,7 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - better-auth@1.7.0(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): + better-auth@1.7.0(patch_hash=1e1eb950e3163ebedbeeb9823f5951a9b9b12cf542ec6b15784bd06ddfb1582e)(@opentelemetry/api@1.9.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.8.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.1013.0)(socks@2.8.7))(mysql2@3.20.0(@types/node@25.4.0))(next@16.2.11(@babel/core@7.29.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): dependencies: '@better-auth/core': 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.9)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.7.0(@better-auth/core@1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b316fc96f8da..1feff3c678d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -351,3 +351,6 @@ minimumReleaseAgeExclude: # Remove this version-pinned patch once upstream defers or safely handles eager seed failures. patchedDependencies: "@better-auth/oauth-provider@1.7.0": patches/@better-auth__oauth-provider@1.7.0.patch + # ENG-2559: a failed secondary-storage session read bypasses Better Auth's configured database + # fallback. Remove this version-pinned patch once upstream handles Redis read failures here. + "better-auth@1.7.0": patches/better-auth@1.7.0.patch From f818da6bfe65df8450bd27f331741c8f60b564aa Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Thu, 3 Sep 2026 05:08:45 +0000 Subject: [PATCH 2/9] fix: support Redis 6 verification token consumption (#9129) --- .../auth/lib/secondary-storage.test.ts | 27 ++++++++++++++++--- .../web/modules/auth/lib/secondary-storage.ts | 23 +++++++++++++++- apps/web/vite.config.mts | 1 - 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/web/modules/auth/lib/secondary-storage.test.ts b/apps/web/modules/auth/lib/secondary-storage.test.ts index 16fa73877ada..8b7e5c50019f 100644 --- a/apps/web/modules/auth/lib/secondary-storage.test.ts +++ b/apps/web/modules/auth/lib/secondary-storage.test.ts @@ -49,10 +49,29 @@ describe("redisSecondaryStorage", () => { expect(mockClient.del).toHaveBeenCalledWith("k"); }); - test("getAndDelete uses GETDEL for atomic single-use consumption", async () => { - mockClient.getDel.mockResolvedValue("once"); - expect(await redisSecondaryStorage.getAndDelete("k")).toBe("once"); - expect(mockClient.getDel).toHaveBeenCalledWith("k"); + test("getAndDelete uses GETDEL when supported and otherwise remembers the atomic Lua fallback", async () => { + const connectionError = new Error("Socket closed unexpectedly"); + mockClient.getDel + .mockResolvedValueOnce("native") + .mockRejectedValueOnce(connectionError) + .mockRejectedValueOnce( + new Error("ERR unknown command `GETDEL`, with args beginning with: `verification:key`") + ); + mockClient.eval.mockResolvedValueOnce("once").mockResolvedValueOnce(null); + + expect(await redisSecondaryStorage.getAndDelete("native-key")).toBe("native"); + await expect(redisSecondaryStorage.getAndDelete("closed-key")).rejects.toBe(connectionError); + expect(await redisSecondaryStorage.getAndDelete("verification:key")).toBe("once"); + expect(await redisSecondaryStorage.getAndDelete("verification:key")).toBeNull(); + + expect(mockClient.getDel).toHaveBeenCalledTimes(3); + expect(mockClient.getDel).toHaveBeenNthCalledWith(1, "native-key"); + expect(mockClient.getDel).toHaveBeenNthCalledWith(2, "closed-key"); + expect(mockClient.getDel).toHaveBeenNthCalledWith(3, "verification:key"); + expect(mockClient.eval).toHaveBeenCalledTimes(2); + const [, options] = mockClient.eval.mock.calls[0]; + expect(options).toEqual({ keys: ["verification:key"], arguments: [] }); + expect(mockClient.eval.mock.calls[1][1]).toEqual({ keys: ["verification:key"], arguments: [] }); }); test("increment runs a single atomic INCR+EXPIRE Lua eval and returns the count", async () => { diff --git a/apps/web/modules/auth/lib/secondary-storage.ts b/apps/web/modules/auth/lib/secondary-storage.ts index 52c7ce5670ed..4a42a6039d6e 100644 --- a/apps/web/modules/auth/lib/secondary-storage.ts +++ b/apps/web/modules/auth/lib/secondary-storage.ts @@ -16,6 +16,17 @@ import { env } from "@/lib/env"; type RedisClient = ReturnType; let clientPromise: Promise | undefined; +let supportsGetDel = true; + +const GET_AND_DELETE_SCRIPT = `local value = redis.call('GET', KEYS[1]) +if value then redis.call('DEL', KEYS[1]) end +return value`; + +const isGetDelUnsupportedError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + const message = error.message.toLowerCase(); + return message.includes("unknown command") && message.includes("getdel"); +}; const getClient = (): Promise => { if (!clientPromise) { @@ -54,7 +65,17 @@ export const redisSecondaryStorage = { // consumed twice under concurrent requests on different instances. getAndDelete: async (key: string): Promise => { const client = await getClient(); - return client.getDel(key); + if (supportsGetDel) { + try { + return await client.getDel(key); + } catch (error) { + if (!isGetDelUnsupportedError(error)) throw error; + supportsGetDel = false; + } + } + + const value = await client.eval(GET_AND_DELETE_SCRIPT, { keys: [key], arguments: [] }); + return typeof value === "string" ? value : null; }, // Atomic rate-limit counter across instances. INCR + EXPIRE-on-first-write run in a single Lua eval // so a crash between the two can't leave a TTL-less (never-expiring) counter that would wedge the diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index a998b3d4eef4..b2f5b6ce3486 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -149,7 +149,6 @@ export default defineConfig({ // tests, so they are excluded from the unit-coverage gate below (ENG-1054). "modules/auth/lib/auth.ts", "modules/auth/lib/auth-client.ts", - "modules/auth/lib/secondary-storage.ts", "modules/auth/lib/better-auth-email-verification.ts", "packages/js-core/src/index.ts", // JS Core index file From 5f686786c43cd03b2e561123b45ad1c9593e8c98 Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Thu, 3 Sep 2026 05:16:19 +0000 Subject: [PATCH 3/9] fix: keep Redis connections alive (#9124) --- .../auth/lib/secondary-storage.test.ts | 26 +++++++++++++++++++ .../web/modules/auth/lib/secondary-storage.ts | 8 +++++- packages/cache/src/client.test.ts | 2 ++ packages/cache/src/client.ts | 3 +++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/web/modules/auth/lib/secondary-storage.test.ts b/apps/web/modules/auth/lib/secondary-storage.test.ts index 8b7e5c50019f..57ff554a738a 100644 --- a/apps/web/modules/auth/lib/secondary-storage.test.ts +++ b/apps/web/modules/auth/lib/secondary-storage.test.ts @@ -1,3 +1,4 @@ +import { createClient } from "redis"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { redisSecondaryStorage } from "./secondary-storage"; @@ -90,4 +91,29 @@ describe("redisSecondaryStorage", () => { mockClient.eval.mockResolvedValue("5"); expect(await redisSecondaryStorage.increment("k", 90)).toBe(5); }); + + test("creates the client with a heartbeat", async () => { + vi.resetModules(); + const { redisSecondaryStorage: freshStorage } = await import("./secondary-storage"); + mockClient.get.mockResolvedValue("value"); + + expect(await freshStorage.get("k")).toBe("value"); + expect(createClient).toHaveBeenCalledWith({ + url: "redis://localhost:6379", + socket: { connectTimeout: 3000 }, + pingInterval: 300_000, + }); + }); + + test("retries the lazy connection after a failed connect", async () => { + vi.resetModules(); + const connectionError = new Error("Connection failed"); + mockClient.connect.mockRejectedValueOnce(connectionError).mockResolvedValueOnce(undefined); + mockClient.get.mockResolvedValue("value"); + const { redisSecondaryStorage: freshStorage } = await import("./secondary-storage"); + + await expect(freshStorage.get("k")).rejects.toBe(connectionError); + await expect(freshStorage.get("k")).resolves.toBe("value"); + expect(mockClient.connect).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/modules/auth/lib/secondary-storage.ts b/apps/web/modules/auth/lib/secondary-storage.ts index 4a42a6039d6e..25b91e572015 100644 --- a/apps/web/modules/auth/lib/secondary-storage.ts +++ b/apps/web/modules/auth/lib/secondary-storage.ts @@ -30,7 +30,13 @@ const isGetDelUnsupportedError = (error: unknown): boolean => { const getClient = (): Promise => { if (!clientPromise) { - const client = createClient({ url: env.REDIS_URL, socket: { connectTimeout: 3000 } }); + const client = createClient({ + url: env.REDIS_URL, + socket: { connectTimeout: 3000 }, + // Managed Redis services and their network paths can reap idle connections (for example, + // Azure Cache for Redis documents a 10-minute idle timeout). Ping well inside that limit. + pingInterval: 300_000, + }); client.on("error", (error) => logger.error(error, "Better Auth Redis secondary storage error")); clientPromise = client .connect() diff --git a/packages/cache/src/client.test.ts b/packages/cache/src/client.test.ts index 3047d1fcf4ad..1789aab216f2 100644 --- a/packages/cache/src/client.test.ts +++ b/packages/cache/src/client.test.ts @@ -100,6 +100,7 @@ describe("@formbricks/cache factory", () => { socket: { connectTimeout: 3000, }, + pingInterval: 300_000, }); // Verify event handlers are set up @@ -138,6 +139,7 @@ describe("@formbricks/cache factory", () => { socket: { connectTimeout: 3000, }, + pingInterval: 300_000, }); expect(mockClient.connect).toHaveBeenCalled(); }); diff --git a/packages/cache/src/client.ts b/packages/cache/src/client.ts index f4d1e68e1f50..51d7a45742e1 100644 --- a/packages/cache/src/client.ts +++ b/packages/cache/src/client.ts @@ -22,6 +22,9 @@ export async function createRedisClientFromEnv(): Promise { From 9a05aa42950439d8d41a5bc80c3928d29209bd32 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:26:58 +0000 Subject: [PATCH 4/9] fix(js-core): keep identified users through the legacy config migration [ENG-2480] (#9112) --- packages/js-core/src/lib/common/setup.ts | 84 ++++-- .../src/lib/common/tests/setup.test.ts | 281 ++++++++++++++++++ packages/js-core/src/types/config.ts | 10 +- 3 files changed, 354 insertions(+), 21 deletions(-) diff --git a/packages/js-core/src/lib/common/setup.ts b/packages/js-core/src/lib/common/setup.ts index 97abdfdf2fd7..2acd97b0e635 100644 --- a/packages/js-core/src/lib/common/setup.ts +++ b/packages/js-core/src/lib/common/setup.ts @@ -68,6 +68,44 @@ const migrateLocalStorage = (): { changed: boolean; newState?: TLegacyConfig } = return { changed: false }; }; +/** + * Rebuild a legacy `user` into a complete `TUserState`. + * + * A legacy blob is unchecked JSON: `data` can be absent, or present with missing fields or wrong + * types. Spreading it over the default is not enough on its own — a spread only fills keys that are + * absent, so a stored `displays: null` would win over the default and reach `filterSurveys`, which + * calls `.filter` on it. The three fields it destructures as arrays are therefore type-checked. + * + * A repaired state is marked already-expired rather than trusted, because a half-empty one is not + * merely incomplete, it is wrong: an identified user completed to `segments: []` is filtered down + * to no surveys at all. Expiring it makes setup re-sync instead of rendering nothing. + */ +const completeLegacyUserState = (legacyUser: TLegacyConfig["user"]): TUserState => { + const legacyData = legacyUser?.data; + + if (!legacyData) { + return DEFAULT_USER_STATE_NO_USER_ID; + } + + const defaults = DEFAULT_USER_STATE_NO_USER_ID.data; + const wasRepaired = + !Array.isArray(legacyData.segments) || + !Array.isArray(legacyData.displays) || + !Array.isArray(legacyData.responses); + + return { + // `new Date(0)` reads as expired wherever `expiresAt` is checked, which is what forces the sync. + expiresAt: wasRepaired ? new Date(0) : (legacyUser.expiresAt ?? null), + data: { + ...defaults, + ...legacyData, + segments: Array.isArray(legacyData.segments) ? legacyData.segments : defaults.segments, + displays: Array.isArray(legacyData.displays) ? legacyData.displays : defaults.displays, + responses: Array.isArray(legacyData.responses) ? legacyData.responses : defaults.responses, + }, + }; +}; + export const setup = async ( configInput: TConfigInput | (TConfigInput & { userId: string; attributes: Record }) ): Promise> => { @@ -86,20 +124,11 @@ export const setup = async ( config.resetConfig(); config = Config.getInstance(); - // If the js sdk is being used for non identified users, and we have a new state to update to after migrating, we update the state - // otherwise, we just sync again! - - if (newState && !newState.user?.data?.userId) { - // Legacy configs could be persisted without a user state, or with a user missing `data` — - // substitute the default (rebuilding a complete state when only `data` survived) so - // downstream `config.user` reads keep working and the resync path still runs. - const legacyUser = newState.user; - config.update({ - ...newState, - user: legacyUser?.data - ? { expiresAt: legacyUser.expiresAt ?? null, data: legacyUser.data } - : DEFAULT_USER_STATE_NO_USER_ID, - }); + // Persist the migrated state for every legacy config, identified or not. `resetConfig()` has + // just wiped storage, so skipping the write leaves no config at all and the fresh-setup path + // below would silently downgrade an identified user to anonymous. + if (newState) { + config.update({ ...newState, user: completeLegacyUserState(newState.user) }); } } @@ -296,13 +325,26 @@ export const setup = async ( let userState: TUserState = DEFAULT_USER_STATE_NO_USER_ID; - if ("userId" in configInput && configInput.userId) { + // A migrated legacy config reaches this branch whenever it carried no workspace state to + // reuse. It can still carry an identified user, so fall back to that id when setup was not + // given one — otherwise the migration would turn an identified user into an anonymous one. + // Scoped to the config the migration just wrote: another workspace or app URL is another + // integration, and its user must not be carried across. + const migratedUser = + changed && existingConfig?.workspaceId === effectiveId && existingConfig.appUrl === configInput.appUrl + ? existingConfig.user + : null; + const setupUserId = "userId" in configInput && configInput.userId ? configInput.userId : null; + const userId = setupUserId ?? migratedUser?.data.userId ?? null; + + if (userId) { const updatesResponse = await sendUpdatesToBackend({ appUrl: configInput.appUrl, workspaceId: effectiveId, updates: { - userId: configInput.userId, - attributes: configInput.attributes, + userId, + // Attributes only ever come from the setup call, never from the migrated state. + ...(setupUserId && "attributes" in configInput ? { attributes: configInput.attributes } : {}), }, }); @@ -312,6 +354,14 @@ export const setup = async ( logger.error( `Error updating user state: ${updatesResponse.error.code} - ${updatesResponse.error.responseMessage ?? ""}` ); + + // The migration is one-shot: the config just written no longer looks legacy, so `changed` + // is false on every later load and this id is gone for good if it is not kept now. Keep + // the migrated state, expired, so the next load retries the sync instead of persisting + // the default and stranding the user as anonymous. + if (!setupUserId && migratedUser) { + userState = { ...migratedUser, expiresAt: new Date(0) }; + } } } diff --git a/packages/js-core/src/lib/common/tests/setup.test.ts b/packages/js-core/src/lib/common/tests/setup.test.ts index dcda5641fb9b..0ad715b24cbf 100644 --- a/packages/js-core/src/lib/common/tests/setup.test.ts +++ b/packages/js-core/src/lib/common/tests/setup.test.ts @@ -11,6 +11,7 @@ import { addLiveRegionContainer } from "@/lib/survey/widget"; import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state"; import { sendUpdatesToBackend } from "@/lib/user/update"; import { fetchWorkspaceState } from "@/lib/workspace/state"; +import type { TConfig, TConfigUpdateInput } from "@/types/config"; const setItemMock = localStorage.setItem as unknown as Mock; @@ -77,6 +78,34 @@ vi.mock("@/lib/survey/widget", () => ({ addLiveRegionContainer: vi.fn(), })); +interface StatefulConfigMock { + get: Mock; + update: Mock; + resetConfig: Mock; + read: () => TConfig | undefined; +} + +// Stateful stand-in for Config: `get()` throws until something is written and then returns the last +// value written, like the real one. A mock whose `get()` always throws cannot observe whether state +// survived the migration at all, which is the whole question these tests ask. +const createStatefulConfigMock = (): StatefulConfigMock => { + let stored: TConfig | undefined; + + return { + get: vi.fn(() => { + if (!stored) throw new Error("config is null"); + return stored; + }), + update: vi.fn((next: TConfigUpdateInput) => { + stored = { ...next, status: { value: "success", expiresAt: null } }; + }), + resetConfig: vi.fn(() => { + stored = undefined; + }), + read: () => stored, + }; +}; + describe("setup.ts", () => { let getInstanceConfigMock: MockInstance<() => Config>; let getInstanceLoggerMock: MockInstance<() => Logger>; @@ -544,6 +573,258 @@ describe("setup.ts", () => { ); }); + test("keeps an identified legacy user through the migration", async () => { + const legacyUserData = { + userId: "user_1", + contactId: "contact_1", + segments: ["seg_1"], + displays: [], + responses: [], + lastDisplayAt: null, + }; + (localStorage.getItem as unknown as Mock).mockReturnValueOnce( + JSON.stringify({ + appUrl: "https://urlX", + environmentId: "ws_123", + user: { expiresAt: null, data: legacyUserData }, + }) + ); + + const mockConfig = createStatefulConfigMock(); + + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { data: { surveys: [], expiresAt: new Date(Date.now() + 60000) } }, + }); + (sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { state: { expiresAt: new Date(Date.now() + 60000), data: legacyUserData } }, + }); + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ workspaceId: "ws_123", appUrl: "https://urlX" }); + + expect(result.ok).toBe(true); + // the identified user was persisted rather than dropped with the reset config + expect(mockConfig.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ workspaceId: "ws_123", user: { expiresAt: null, data: legacyUserData } }) + ); + // and the fresh-sync path re-identified from it instead of falling back to anonymous + expect(sendUpdatesToBackend).toHaveBeenCalledWith( + expect.objectContaining({ updates: { userId: "user_1" } }) + ); + expect(mockConfig.read()?.user.data.userId).toBe("user_1"); + }); + + // The migrated config below belongs to ws_123 on https://urlX. Either mismatch means a + // different integration, whose user must not be carried across. + test.each([ + { label: "workspace", input: { workspaceId: "ws_999", appUrl: "https://urlX" } }, + { label: "app URL", input: { workspaceId: "ws_123", appUrl: "https://other.url" } }, + ])("does not carry a migrated user into a different $label", async ({ input }) => { + (localStorage.getItem as unknown as Mock).mockReturnValueOnce( + JSON.stringify({ + appUrl: "https://urlX", + environmentId: "ws_123", + user: { + expiresAt: null, + data: { + userId: "user_1", + contactId: "contact_1", + segments: [], + displays: [], + responses: [], + lastDisplayAt: null, + }, + }, + }) + ); + + const mockConfig = createStatefulConfigMock(); + + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { data: { surveys: [], expiresAt: new Date(Date.now() + 60000) } }, + }); + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup(input); + + expect(result.ok).toBe(true); + expect(sendUpdatesToBackend).not.toHaveBeenCalled(); + expect(mockConfig.read()?.user.data.userId).toBeNull(); + }); + + test("keeps the migrated user when the identify call fails", async () => { + const legacyUserData = { + userId: "user_1", + contactId: "contact_1", + segments: ["seg_1"], + displays: [], + responses: [], + lastDisplayAt: null, + }; + (localStorage.getItem as unknown as Mock).mockReturnValueOnce( + JSON.stringify({ + appUrl: "https://urlX", + environmentId: "ws_123", + user: { expiresAt: null, data: legacyUserData }, + }) + ); + + const mockConfig = createStatefulConfigMock(); + + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { data: { surveys: [], expiresAt: new Date(Date.now() + 60000) } }, + }); + (sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({ + ok: false, + error: { code: "network_error", responseMessage: "boom" }, + }); + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ workspaceId: "ws_123", appUrl: "https://urlX" }); + + expect(result.ok).toBe(true); + // the migration is one-shot, so a failed sync must not persist the anonymous default + expect(mockConfig.read()?.user.data).toEqual(legacyUserData); + // kept expired, so the next load retries the sync + expect(mockConfig.read()?.user.expiresAt).toEqual(new Date(0)); + }); + + test("repairs a legacy user whose data holds the wrong types", async () => { + (localStorage.getItem as unknown as Mock).mockReturnValueOnce( + JSON.stringify({ + appUrl: "https://urlX", + environmentId: "ws_123", + // a spread over the default keeps this null, and `filterSurveys` calls `.filter` on it + user: { data: { userId: "user_1", displays: null, responses: [], segments: [] } }, + }) + ); + + const mockConfig = createStatefulConfigMock(); + + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { data: { surveys: [], expiresAt: new Date(Date.now() + 60000) } }, + }); + (sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({ + ok: false, + error: { code: "network_error", responseMessage: "boom" }, + }); + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ workspaceId: "ws_123", appUrl: "https://urlX" }); + + expect(result.ok).toBe(true); + const [migrated] = mockConfig.update.mock.calls[0] as [TConfigUpdateInput]; + expect(migrated.user.data.displays).toEqual([]); + }); + + test("re-syncs a migrated user whose data had to be completed", async () => { + // `segments` is missing. Completing it to [] and trusting it would send an identified user + // through `filterSurveys`'s `if (!segments.length) return []` — no surveys at all. + (localStorage.getItem as unknown as Mock).mockReturnValueOnce( + JSON.stringify({ + appUrl: "https://urlX", + environmentId: "ws_123", + environment: { + expiresAt: new Date(Date.now() + 60000).toISOString(), + data: { surveys: [], settings: {} }, + }, + user: { + // not expired on its own — only the repair marks it for a re-sync + expiresAt: new Date(Date.now() + 60000).toISOString(), + data: { userId: "user_1", contactId: "contact_1", displays: [], responses: [] }, + }, + }) + ); + + const mockConfig = createStatefulConfigMock(); + + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); + (isNowExpired as unknown as Mock).mockImplementation( + (date: Date) => new Date(date).getTime() < Date.now() + ); + (sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { + state: { + expiresAt: new Date(Date.now() + 60000), + data: { userId: "user_1", segments: ["seg_1"], displays: [], responses: [] }, + }, + }, + }); + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ workspaceId: "ws_123", appUrl: "https://urlX" }); + + expect(result.ok).toBe(true); + expect(sendUpdatesToBackend).toHaveBeenCalledWith( + expect.objectContaining({ updates: { userId: "user_1" } }) + ); + expect(mockConfig.read()?.user.data.segments).toEqual(["seg_1"]); + }); + + test("completes a legacy user whose data is present but empty", async () => { + // `Partial` types a present `data` as complete, so an empty one reaches + // `filterSurveys`, which destructures `displays` and calls `.filter` on it. + (localStorage.getItem as unknown as Mock).mockReturnValueOnce( + JSON.stringify({ + appUrl: "https://urlX", + environmentId: "ws_123", + user: { data: {} }, + }) + ); + + const mockConfig = { + get: () => { + throw new Error("no config found"); + }, + resetConfig: vi.fn(), + update: vi.fn(), + }; + + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); + + (fetchWorkspaceState as unknown as Mock).mockResolvedValueOnce({ + ok: true, + data: { data: { surveys: [], expiresAt: new Date(Date.now() + 60000) } }, + }); + (filterSurveys as unknown as Mock).mockReturnValueOnce([]); + + const result = await setup({ workspaceId: "ws_123", appUrl: "https://urlX" }); + + expect(result.ok).toBe(true); + // completed from the default, and expired so setup re-syncs rather than trusting the repair + expect(mockConfig.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + user: { + expiresAt: new Date(0), + data: { + userId: null, + contactId: null, + segments: [], + displays: [], + responses: [], + lastDisplayAt: null, + }, + }, + }) + ); + }); + test("calls handleErrorOnFirstSetup if workspace state fetch fails initially", async () => { const mockConfig = { get: vi.fn().mockReturnValue(undefined), diff --git a/packages/js-core/src/types/config.ts b/packages/js-core/src/types/config.ts index 3d788c9fa36c..8c8b410a9655 100644 --- a/packages/js-core/src/types/config.ts +++ b/packages/js-core/src/types/config.ts @@ -269,10 +269,12 @@ export interface TLegacyConfigInput { } export type TLegacyConfig = Omit & { - // Optional and partial, unlike TConfig: a legacy blob is unchecked JSON from localStorage — - // old first-migration formats persisted user-less configs, and a present `user` may still - // lack `data`/`expiresAt`, so no inner field can be trusted either. - user?: Partial; + // Optional and partial all the way down, unlike TConfig: a legacy blob is unchecked JSON from + // localStorage — old first-migration formats persisted user-less configs, and a present `user` + // may still lack `data`/`expiresAt` or carry an incomplete `data`. `Partial` would + // only make `data` itself optional and type a present one as complete, which is the lie this + // spells out: nothing inside `data` can be trusted either. + user?: { expiresAt?: Date | null; data?: Partial }; apiHost?: string; attributes?: TAttributes; // Intermediate format fields (pre-workspace rename) From 7b721c595e4f648908dd73a33ca271aa924d4e63 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:59:43 +0000 Subject: [PATCH 5/9] feat: allow hiding the default checkmark icon on ending cards (ENG-1787) (#9083) --- apps/web/app/api/v3/surveys/schemas.test.ts | 19 ++++++++++++++ apps/web/app/api/v3/surveys/schemas.ts | 1 + apps/web/i18n.lock | 4 ++- apps/web/locales/de-DE.json | 4 ++- apps/web/locales/en-US.json | 4 ++- apps/web/locales/es-ES.json | 2 ++ apps/web/locales/fr-FR.json | 2 ++ apps/web/locales/hu-HU.json | 2 ++ apps/web/locales/ja-JP.json | 2 ++ apps/web/locales/nl-NL.json | 4 ++- apps/web/locales/pt-BR.json | 4 ++- apps/web/locales/pt-PT.json | 4 ++- apps/web/locales/ro-RO.json | 2 ++ apps/web/locales/ru-RU.json | 2 ++ apps/web/locales/sv-SE.json | 2 ++ apps/web/locales/tr-TR.json | 4 ++- apps/web/locales/zh-Hans-CN.json | 4 ++- apps/web/locales/zh-Hant-TW.json | 2 ++ .../editor/components/end-screen-form.tsx | 25 +++++++++++++++++++ docs/api-v3-reference/openapi.yml | 3 +++ .../schemas/SurveyEndScreenEnding.yml | 3 +++ .../src/components/general/ending-card.tsx | 2 +- packages/types/surveys/types.ts | 3 +++ 23 files changed, 95 insertions(+), 9 deletions(-) diff --git a/apps/web/app/api/v3/surveys/schemas.test.ts b/apps/web/app/api/v3/surveys/schemas.test.ts index c0ac3afc6573..94d15e14d711 100644 --- a/apps/web/app/api/v3/surveys/schemas.test.ts +++ b/apps/web/app/api/v3/surveys/schemas.test.ts @@ -592,6 +592,25 @@ describe("ZV3CreateSurveyBody", () => { } }); + test("accepts hideDefaultIcon on an end screen ending", () => { + const result = ZV3CreateSurveyBody.safeParse({ + ...validCreateBody, + endings: [ + { + id: "clend123456789012345678901", + type: "endScreen", + headline: { "en-US": "Thanks!" }, + hideDefaultIcon: true, + }, + ], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endings?.[0]).toMatchObject({ hideDefaultIcon: true }); + } + }); + test("reports missing required ending fields before shared ending union errors", () => { const result = ZV3CreateSurveyBody.safeParse({ ...validCreateBody, diff --git a/apps/web/app/api/v3/surveys/schemas.ts b/apps/web/app/api/v3/surveys/schemas.ts index 851c068fa1f1..1326500b6528 100644 --- a/apps/web/app/api/v3/surveys/schemas.ts +++ b/apps/web/app/api/v3/surveys/schemas.ts @@ -453,6 +453,7 @@ const END_SCREEN_KEYS = new Set([ "buttonLink", "imageUrl", "videoUrl", + "hideDefaultIcon", ]); const REDIRECT_ENDING_KEYS = new Set(["id", "type", "url", "label"]); const ENDING_REQUIRED_KEYS = ["id", "type"] as const; diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 083230b2b2bb..49d13df9292a 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -3362,7 +3362,9 @@ checksums: workspace/surveys/edit/settings_saved_successfully: 7f6833d9079e404fb3a5b0aa51fdcf17 workspace/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42 workspace/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79 - workspace/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc + workspace/surveys/edit/show_button: 04cf19ed96d36b2afaabb176a9fcccfa + workspace/surveys/edit/show_checkmark_icon: bf625ac12995bd173f4e634e0769189f + workspace/surveys/edit/show_checkmark_icon_description: 0385ccad74b7af18173b1970fffaebe3 workspace/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493 workspace/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413 workspace/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 5f08e6bccdd6..d3118bfc524d 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "Einstellungen erfolgreich gespeichert", "seven_points": "7 Punkte", "show_block_settings": "Block-Einstellungen anzeigen", - "show_button": "Button anzeigen", + "show_button": "Schaltfläche anzeigen", + "show_checkmark_icon": "Häkchen-Symbol anzeigen", + "show_checkmark_icon_description": "Zeige das Häkchen-Symbol über deinem Text an.", "show_in_order": "In Reihenfolge anzeigen", "show_language_switch": "Sprachwechsel anzeigen", "show_multiple_times": "Begrenzte Anzahl von Malen anzeigen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index cdca607f51ff..48c87c28b57f 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "Settings saved successfully", "seven_points": "7 points", "show_block_settings": "Show Block settings", - "show_button": "Show Button", + "show_button": "Show button", + "show_checkmark_icon": "Show checkmark icon", + "show_checkmark_icon_description": "Display the checkmark icon above your text.", "show_in_order": "Show in order", "show_language_switch": "Show language switch", "show_multiple_times": "Show a limited number of times", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 2d0f2d3c10f7..720c4ed4efe0 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -3482,6 +3482,8 @@ "seven_points": "7 puntos", "show_block_settings": "Mostrar ajustes del bloque", "show_button": "Mostrar botón", + "show_checkmark_icon": "Mostrar icono de confirmación", + "show_checkmark_icon_description": "Muestra el icono de confirmación encima de tu texto.", "show_in_order": "Mostrar en orden", "show_language_switch": "Mostrar cambio de idioma", "show_multiple_times": "Mostrar un número limitado de veces", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 1d5832b57b0c..42bb966a3671 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -3482,6 +3482,8 @@ "seven_points": "7 points", "show_block_settings": "Afficher les paramètres du bloc", "show_button": "Afficher le bouton", + "show_checkmark_icon": "Afficher l'icône de validation", + "show_checkmark_icon_description": "Affiche l'icône de validation au-dessus de ton texte.", "show_in_order": "Afficher dans l'ordre", "show_language_switch": "Afficher le changement de langue", "show_multiple_times": "Afficher un nombre limité de fois", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 47e0a311e837..221470e909f8 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -3482,6 +3482,8 @@ "seven_points": "7 pont", "show_block_settings": "Blokkbeállítások megjelenítése", "show_button": "Gomb megjelenítése", + "show_checkmark_icon": "Pipa ikon megjelenítése", + "show_checkmark_icon_description": "A pipa ikon megjelenítése a szöveg felett.", "show_in_order": "Megjelenítés sorrendben", "show_language_switch": "Nyelvválasztó megjelenítése", "show_multiple_times": "Megjelenítés korlátozott számú alkalommal", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 06b4dd3ce1dc..7c30a67b0aea 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -3482,6 +3482,8 @@ "seven_points": "7点", "show_block_settings": "ブロック設定を表示", "show_button": "ボタンを表示", + "show_checkmark_icon": "チェックマークアイコンを表示", + "show_checkmark_icon_description": "テキストの上にチェックマークアイコンを表示します。", "show_in_order": "順番に表示", "show_language_switch": "言語切り替えを表示", "show_multiple_times": "限られた回数表示する", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 70d07ebf6551..e4b9139f8b92 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "Instellingen succesvol opgeslagen.", "seven_points": "7 punten", "show_block_settings": "Blokinstellingen tonen", - "show_button": "Toon knop", + "show_button": "Knop weergeven", + "show_checkmark_icon": "Vinkje weergeven", + "show_checkmark_icon_description": "Toon het vinkje boven je tekst.", "show_in_order": "Toon op volgorde", "show_language_switch": "Toon taalwissel", "show_multiple_times": "Toon een beperkt aantal keren", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 2a5ca8e4f53e..39cbb06be199 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "Configurações salvas com sucesso", "seven_points": "7 pontos", "show_block_settings": "Mostrar configurações do bloco", - "show_button": "Mostrar Botão", + "show_button": "Mostrar botão", + "show_checkmark_icon": "Mostrar ícone de confirmação", + "show_checkmark_icon_description": "Exibir o ícone de confirmação acima do seu texto.", "show_in_order": "Mostrar em ordem", "show_language_switch": "Mostrar troca de idioma", "show_multiple_times": "Mostrar um número limitado de vezes", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 0a90e7796333..6d7460d6137f 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "Definições guardadas com sucesso", "seven_points": "7 pontos", "show_block_settings": "Mostrar definições do bloco", - "show_button": "Mostrar Botão", + "show_button": "Mostrar botão", + "show_checkmark_icon": "Mostrar ícone de visto", + "show_checkmark_icon_description": "Apresenta o ícone de visto acima do teu texto.", "show_in_order": "Mostrar por ordem", "show_language_switch": "Mostrar alternador de idioma", "show_multiple_times": "Mostrar um número limitado de vezes", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index d4ee49072879..bbc8750c12e0 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -3482,6 +3482,8 @@ "seven_points": "7 puncte", "show_block_settings": "Afișează setările blocului", "show_button": "Afișează butonul", + "show_checkmark_icon": "Afișează iconița de bifat", + "show_checkmark_icon_description": "Afișează iconița de bifat deasupra textului tău.", "show_in_order": "Afișează în ordine", "show_language_switch": "Afișează comutatorul de limbă", "show_multiple_times": "Afișează de mai multe ori", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 60eccb6b4ed9..4ec7e6331c72 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -3482,6 +3482,8 @@ "seven_points": "7 баллов", "show_block_settings": "Показать настройки блока", "show_button": "Показать кнопку", + "show_checkmark_icon": "Показать значок галочки", + "show_checkmark_icon_description": "Отображать значок галочки над текстом.", "show_in_order": "Показать по порядку", "show_language_switch": "Показать переключатель языка", "show_multiple_times": "Показать ограниченное количество раз", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 228773d74468..a49904977821 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -3482,6 +3482,8 @@ "seven_points": "7 poäng", "show_block_settings": "Visa blockinställningar", "show_button": "Visa knapp", + "show_checkmark_icon": "Visa bockikon", + "show_checkmark_icon_description": "Visa bockikonen ovanför din text.", "show_in_order": "Visa i ordning", "show_language_switch": "Visa språkväxlare", "show_multiple_times": "Visa ett begränsat antal gånger", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 8a39017045a6..de8cebcfafa3 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "Ayarlar başarıyla kaydedildi", "seven_points": "7 nokta", "show_block_settings": "Blok ayarlarını göster", - "show_button": "Düğmeyi Göster", + "show_button": "Düğmeyi göster", + "show_checkmark_icon": "Onay simgesini göster", + "show_checkmark_icon_description": "Metninizin üzerinde onay simgesini görüntüleyin.", "show_in_order": "Sırayla göster", "show_language_switch": "Dil değiştiricisini göster", "show_multiple_times": "Sınırlı sayıda göster", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 435e5d03b3c1..6465d7bde1c0 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -3481,7 +3481,9 @@ "settings_saved_successfully": "设置 保存 成功", "seven_points": "7 分", "show_block_settings": "显示区块设置", - "show_button": "显示 按钮", + "show_button": "显示按钮", + "show_checkmark_icon": "显示对勾图标", + "show_checkmark_icon_description": "在文本上方显示对勾图标。", "show_in_order": "按顺序显示", "show_language_switch": "显示 语言 切换", "show_multiple_times": "显示有限次数", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index f69ca8586d81..4ec8d1db7d7d 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -3482,6 +3482,8 @@ "seven_points": "7 分", "show_block_settings": "顯示區塊設定", "show_button": "顯示按鈕", + "show_checkmark_icon": "顯示勾選圖示", + "show_checkmark_icon_description": "在文字上方顯示勾選圖示。", "show_in_order": "依序顯示", "show_language_switch": "顯示語言切換", "show_multiple_times": "顯示有限次數", diff --git a/apps/web/modules/survey/editor/components/end-screen-form.tsx b/apps/web/modules/survey/editor/components/end-screen-form.tsx index f22f76369cab..a088e987b5a9 100644 --- a/apps/web/modules/survey/editor/components/end-screen-form.tsx +++ b/apps/web/modules/survey/editor/components/end-screen-form.tsx @@ -54,6 +54,10 @@ export const EndScreenForm = ({ (!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink) ); + // The checkmark only renders when the card carries no media, so the toggle is meaningless once an + // image or video is set. + const hasMedia = Boolean(endingCard.imageUrl ?? endingCard.videoUrl); + return (
)} + {!hasMedia && ( +
+ { + updateSurvey({ hideDefaultIcon: checked ? undefined : true }); + }} + /> + +
+ )}
{endingCard.type === "endScreen" && (
- {media ?? checkmark} + {media ?? (endingCard.hideDefaultIcon ? null : checkmark)}
; From b04b4b0de34a4688f6e17b7f5f6f3e17ad6f29c4 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:06:27 +0000 Subject: [PATCH 6/9] feat(ai): add an AI UI kit and stream survey generation (ENG-1248) (#9027) --- .../workspaces/new/ai/page.tsx | 5 +- .../survey/components/create-first-survey.tsx | 5 +- .../surveys/generate/lib/error-events.test.ts | 69 ++++ .../surveys/generate/lib/error-events.ts | 76 ++++ .../surveys/generate/lib/events.test.ts | 91 +++++ .../internal/surveys/generate/lib/events.ts | 89 +++++ .../surveys/generate/lib/operations.test.ts | 198 ++++++++++ .../surveys/generate/lib/operations.ts | 195 ++++++++++ .../internal/surveys/generate/stream/route.ts | 43 +++ .../app/api/v3/surveys/generate/service.ts | 93 +++-- apps/web/i18n.lock | 29 +- apps/web/lib/ai/service.test.ts | 65 ++++ apps/web/lib/ai/service.ts | 147 ++++++-- apps/web/locales/de-DE.json | 31 +- apps/web/locales/en-US.json | 31 +- apps/web/locales/es-ES.json | 31 +- apps/web/locales/fr-FR.json | 31 +- apps/web/locales/hu-HU.json | 31 +- apps/web/locales/ja-JP.json | 31 +- apps/web/locales/nl-NL.json | 31 +- apps/web/locales/pt-BR.json | 31 +- apps/web/locales/pt-PT.json | 31 +- apps/web/locales/ro-RO.json | 31 +- apps/web/locales/ru-RU.json | 31 +- apps/web/locales/sv-SE.json | 31 +- apps/web/locales/tr-TR.json | 27 +- apps/web/locales/zh-Hans-CN.json | 31 +- apps/web/locales/zh-Hant-TW.json | 31 +- .../components/ai-draft-preview.tsx | 194 ++++++++++ .../components/create-with-ai-dialog.tsx | 100 +++-- .../components/create-with-ai-form.tsx | 309 +++++++++++---- .../components/create-with-ai-template.tsx | 4 +- .../hooks/use-create-survey-with-ai.test.ts | 355 +++++++++++------- .../hooks/use-create-survey-with-ai.ts | 276 ++++++++++---- .../lib/ai-create-machine.test.ts | 311 +++++++++++++++ .../template-list/lib/ai-create-machine.ts | 211 +++++++++++ .../lib/ai-draft-reducer.test.ts | 231 ++++++++++++ .../template-list/lib/ai-draft-reducer.ts | 158 ++++++++ .../lib/ai-error-messages.test.ts | 58 +++ .../template-list/lib/ai-error-messages.ts | 52 +++ .../lib/ai-generate-stream-client.test.ts | 62 +++ .../lib/ai-generate-stream-client.ts | 75 ++++ .../lib/ai-stream-parser.test.ts | 61 +++ .../template-list/lib/ai-stream-parser.ts | 45 +++ .../list/components/featured-templates.tsx | 4 +- .../survey/list/components/survey-list.tsx | 7 +- .../ai/components/ai-activity-bar.tsx | 27 ++ .../ui/components/ai/components/ai-icon.tsx | 60 +++ .../ai/components/ai-status-line.tsx | 96 +++++ apps/web/modules/ui/components/ai/index.tsx | 15 + .../ui/components/ai/lib/status-line.test.ts | 70 ++++ .../ui/components/ai/lib/status-line.ts | 71 ++++ .../modules/ui/components/button/index.tsx | 6 + apps/web/modules/ui/globals.css | 88 +++++ .../ui/hooks/use-before-unload-prompt.test.ts | 68 ++++ .../ui/hooks/use-before-unload-prompt.ts | 53 +++ packages/ai/src/index.ts | 5 + packages/ai/src/stream-object.test.ts | 189 ++++++++++ packages/ai/src/stream-object.ts | 82 ++++ packages/ai/src/types.ts | 39 +- 60 files changed, 4490 insertions(+), 458 deletions(-) create mode 100644 apps/web/app/api/internal/surveys/generate/lib/error-events.test.ts create mode 100644 apps/web/app/api/internal/surveys/generate/lib/error-events.ts create mode 100644 apps/web/app/api/internal/surveys/generate/lib/events.test.ts create mode 100644 apps/web/app/api/internal/surveys/generate/lib/events.ts create mode 100644 apps/web/app/api/internal/surveys/generate/lib/operations.test.ts create mode 100644 apps/web/app/api/internal/surveys/generate/lib/operations.ts create mode 100644 apps/web/app/api/internal/surveys/generate/stream/route.ts create mode 100644 apps/web/modules/survey/components/template-list/components/ai-draft-preview.tsx create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-create-machine.test.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-create-machine.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-draft-reducer.test.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-draft-reducer.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-error-messages.test.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-error-messages.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-generate-stream-client.test.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-generate-stream-client.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-stream-parser.test.ts create mode 100644 apps/web/modules/survey/components/template-list/lib/ai-stream-parser.ts create mode 100644 apps/web/modules/ui/components/ai/components/ai-activity-bar.tsx create mode 100644 apps/web/modules/ui/components/ai/components/ai-icon.tsx create mode 100644 apps/web/modules/ui/components/ai/components/ai-status-line.tsx create mode 100644 apps/web/modules/ui/components/ai/index.tsx create mode 100644 apps/web/modules/ui/components/ai/lib/status-line.test.ts create mode 100644 apps/web/modules/ui/components/ai/lib/status-line.ts create mode 100644 apps/web/modules/ui/hooks/use-before-unload-prompt.test.ts create mode 100644 apps/web/modules/ui/hooks/use-before-unload-prompt.ts create mode 100644 packages/ai/src/stream-object.test.ts create mode 100644 packages/ai/src/stream-object.ts diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx index 8004ebfeb81c..4840b4f9575c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/ai/page.tsx @@ -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 { @@ -42,7 +43,9 @@ const Page = async (props: AIOnboardingPageProps) => { return (
- + + +
); }; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/survey/components/create-first-survey.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/survey/components/create-first-survey.tsx index 1984961f4e69..baa2cd73e546 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/survey/components/create-first-survey.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/survey/components/create-first-survey.tsx @@ -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"; @@ -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"; @@ -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: () => { diff --git a/apps/web/app/api/internal/surveys/generate/lib/error-events.test.ts b/apps/web/app/api/internal/surveys/generate/lib/error-events.test.ts new file mode 100644 index 000000000000..c2b14066ff44 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/lib/error-events.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/api/internal/surveys/generate/lib/error-events.ts b/apps/web/app/api/internal/surveys/generate/lib/error-events.ts new file mode 100644 index 000000000000..da526ab4a637 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/lib/error-events.ts @@ -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 { + 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], + }; +} diff --git a/apps/web/app/api/internal/surveys/generate/lib/events.test.ts b/apps/web/app/api/internal/surveys/generate/lib/events.test.ts new file mode 100644 index 000000000000..6ed5f6949d78 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/lib/events.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/api/internal/surveys/generate/lib/events.ts b/apps/web/app/api/internal/surveys/generate/lib/events.ts new file mode 100644 index 000000000000..93ac60b63819 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/lib/events.ts @@ -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>; + +/** 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; +} diff --git a/apps/web/app/api/internal/surveys/generate/lib/operations.test.ts b/apps/web/app/api/internal/surveys/generate/lib/operations.test.ts new file mode 100644 index 000000000000..67d92f8e47e5 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/lib/operations.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { OperationNotAllowedError } from "@formbricks/types/errors"; +import type { TV3SurveyGenerateBody } from "@/app/api/v3/surveys/generate/schemas"; +import { streamV3SurveyGeneration } from "./operations"; + +const mocks = vi.hoisted(() => ({ + requireV3WorkspaceAccess: vi.fn(), + getSessionUserId: vi.fn(), + assertOrganizationAIConfigured: vi.fn(), + streamOrganizationAIObject: vi.fn(), + assertV3SurveyGeneratePrompt: vi.fn(), + buildV3SurveyCreatePayloadFromDraft: vi.fn(), + capturePostHogEvent: vi.fn(), +})); + +vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: mocks.requireV3WorkspaceAccess })); +vi.mock("@/app/api/v3/surveys/lib/operations", () => ({ getSessionUserId: mocks.getSessionUserId })); +vi.mock("@/lib/ai/service", async (importOriginal) => ({ + ...(await importOriginal>()), + assertOrganizationAIConfigured: mocks.assertOrganizationAIConfigured, + streamOrganizationAIObject: mocks.streamOrganizationAIObject, +})); +vi.mock("@/app/api/v3/surveys/generate/service", async (importOriginal) => ({ + ...(await importOriginal>()), + assertV3SurveyGeneratePrompt: mocks.assertV3SurveyGeneratePrompt, + buildV3SurveyGenerationRequest: () => ({ prompt: "built" }), + buildV3SurveyGenerationTracing: () => undefined, + buildV3SurveyCreatePayloadFromDraft: mocks.buildV3SurveyCreatePayloadFromDraft, +})); +vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: mocks.capturePostHogEvent })); + +const body: TV3SurveyGenerateBody = { + workspaceId: "workspace1", + prompt: "Understand why new users stop during onboarding", + type: "link", +}; + +const call = (signal?: AbortSignal) => + streamV3SurveyGeneration({ + req: new Request("http://localhost/api/internal/surveys/generate/stream", { method: "POST", signal }), + authentication: { type: "session", session: { user: { id: "user1" } } } as never, + body, + requestId: "req_1", + instance: "/api/internal/surveys/generate/stream", + }); + +/** Drain an NDJSON response body into parsed events. */ +const readEvents = async (response: Response) => { + const text = await response.text(); + return text + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); +}; + +const asyncIterable = (items: T[]) => ({ + async *[Symbol.asyncIterator]() { + for (const item of items) yield item; + }, +}); + +describe("streamV3SurveyGeneration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireV3WorkspaceAccess.mockResolvedValue({ + organizationId: "org1", + workspaceId: "workspace1", + }); + mocks.getSessionUserId.mockReturnValue("user1"); + mocks.assertOrganizationAIConfigured.mockResolvedValue({ isInstanceConfigured: true }); + mocks.buildV3SurveyCreatePayloadFromDraft.mockReturnValue({ + language: "en-US", + payload: { name: "Onboarding" }, + validation: { valid: true, invalid_params: [], languages: [] }, + }); + }); + + test("answers with problem+json and never opens a stream when AI is not entitled", async () => { + // The invariant the whole design turns on: once a 200 with a body has begun there is no way back + // to an RFC 9457 response, so every guard has to run before the first byte. + mocks.assertOrganizationAIConfigured.mockRejectedValueOnce( + new OperationNotAllowedError("ai_smart_tools_disabled") + ); + + const response = await call(); + + // The exact code is error-mapping.test.ts's business; what matters here is that it is a problem + // response at all, which is only possible because no body had been opened. + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.headers.get("Content-Type")).toContain("application/problem+json"); + expect(mocks.streamOrganizationAIObject).not.toHaveBeenCalled(); + }); + + test("answers with problem+json when the prompt is rejected", async () => { + mocks.assertV3SurveyGeneratePrompt.mockImplementationOnce(() => { + throw new OperationNotAllowedError("ai_smart_tools_disabled"); + }); + + const response = await call(); + + expect(response.status).toBeGreaterThanOrEqual(400); + expect(mocks.streamOrganizationAIObject).not.toHaveBeenCalled(); + }); + + test("streams start, partials and done as NDJSON", async () => { + mocks.streamOrganizationAIObject.mockResolvedValue({ + partialObjectStream: asyncIterable([{ name: "Onboarding" }]), + completion: Promise.resolve({ name: "Onboarding", blocks: [] }), + }); + + const response = await call(); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/x-ndjson; charset=utf-8"); + // Set for self-hosters behind nginx, where proxy_buffering would hold the whole response. + expect(response.headers.get("X-Accel-Buffering")).toBe("no"); + + const events = await readEvents(response); + // Two partials: the streamed chunk, then the completed draft as the final snapshot. + expect(events.map((event) => event.type)).toEqual(["start", "partial", "partial", "done"]); + expect(events.at(-1)).toMatchObject({ language: "en-US", payload: { name: "Onboarding" } }); + expect(mocks.capturePostHogEvent).toHaveBeenCalledWith( + "user1", + "ai_survey_generated", + expect.objectContaining({ streamed: true }), + expect.anything() + ); + }); + + test("the final partial carries the completed draft, not the last streamed chunk", async () => { + // The partial stream yields DeepPartials: the last one can be missing fields the finished object + // has. The review step renders this draft while saving uses the payload, so a stale final + // snapshot means the two disagree about what the survey contains. + mocks.streamOrganizationAIObject.mockResolvedValue({ + partialObjectStream: asyncIterable([{ name: "Onboarding", blocks: [{ name: "Block" }] }]), + completion: Promise.resolve({ + name: "Onboarding", + blocks: [{ name: "Block", questions: [{ type: "openText", headline: "How was it?" }] }], + }), + }); + + const events = await readEvents(await call()); + const partials = events.filter((event) => event.type === "partial"); + + expect(partials.at(-1).draft).toEqual({ + name: "Onboarding", + blocks: [{ name: "Block", questions: [{ type: "openText", headline: "How was it?" }] }], + }); + }); + + test("a generation that streams no partials still sends the draft to render", async () => { + // A provider that returns its object in one final chunk yields nothing from the partial stream. + mocks.streamOrganizationAIObject.mockResolvedValue({ + partialObjectStream: asyncIterable([]), + completion: Promise.resolve({ + name: "Onboarding", + blocks: [{ name: "Block", questions: [{ type: "openText", headline: "How was it?" }] }], + }), + }); + + const events = await readEvents(await call()); + + expect(events.map((event) => event.type)).toEqual(["start", "partial", "done"]); + }); + + test("a request that was already aborted starts the generation cancelled", async () => { + // An abort that has already fired is never replayed to a listener registered afterwards, so a + // client that disconnected during the entitlement checks would be billed for a full generation. + mocks.streamOrganizationAIObject.mockResolvedValue({ + partialObjectStream: asyncIterable([]), + completion: Promise.resolve({ name: "Onboarding", blocks: [] }), + }); + + const controller = new AbortController(); + controller.abort(); + + await call(controller.signal); + + const passedSignal = mocks.streamOrganizationAIObject.mock.calls.at(-1)?.[0]?.abortSignal; + expect(passedSignal?.aborted).toBe(true); + }); + + test("reports a mid-generation failure in band and still closes cleanly", async () => { + // controller.error() would truncate the body and the client would see a bare network failure + // with none of the code this event exists to carry. + mocks.streamOrganizationAIObject.mockResolvedValue({ + partialObjectStream: asyncIterable([{ name: "Onboarding" }]), + completion: Promise.reject(new Error("provider exploded")), + }); + + const response = await call(); + const events = await readEvents(response); + + expect(response.status).toBe(200); + expect(events.at(-1)).toMatchObject({ type: "error", code: "ai_generation_failed" }); + expect(mocks.capturePostHogEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/internal/surveys/generate/lib/operations.ts b/apps/web/app/api/internal/surveys/generate/lib/operations.ts new file mode 100644 index 000000000000..bebae3996420 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/lib/operations.ts @@ -0,0 +1,195 @@ +import "server-only"; +import type { z } from "zod"; +import type { TStreamObjectResult } from "@formbricks/ai"; +import { logger } from "@formbricks/logger"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { mapV3SurveyGenerateError } from "@/app/api/v3/surveys/generate/error-mapping"; +import type { + TV3SurveyGenerateBody, + ZGeneratedSurveyDraftForAI, +} from "@/app/api/v3/surveys/generate/schemas"; +import { + assertV3SurveyGeneratePrompt, + buildV3SurveyCreatePayloadFromDraft, + buildV3SurveyGenerationRequest, + buildV3SurveyGenerationTracing, +} from "@/app/api/v3/surveys/generate/service"; +import { getSessionUserId } from "@/app/api/v3/surveys/lib/operations"; +import { assertOrganizationAIConfigured, streamOrganizationAIObject } from "@/lib/ai/service"; +import { capturePostHogEvent } from "@/lib/posthog"; +import { isClientAbort, toStreamErrorEvent } from "./error-events"; +import { + SURVEY_GENERATION_STREAM_CONTENT_TYPE, + type TSurveyGenerationDraftSnapshot, + type TSurveyGenerationStreamEvent, + encodeStreamEvent, + shouldEmitSnapshot, +} from "./events"; + +interface TStreamSurveyGenerationParams { + req: Request; + authentication: TV3Authentication; + body: TV3SurveyGenerateBody; + requestId: string; + instance: string; +} + +/** + * Stream a survey draft as the model writes it, as NDJSON. + * + * The ordering here is the whole design: **every guard runs before the response body opens.** Once + * a 200 with a body has begun there is no way back to an RFC 9457 problem response, so entitlement + * and prompt validation are hoisted ahead of the stream and only genuine mid-generation failures + * become in-band `error` events. + */ +export async function streamV3SurveyGeneration({ + req, + authentication, + body, + requestId, + instance, +}: TStreamSurveyGenerationParams): Promise { + const workspaceAccess = await requireV3WorkspaceAccess( + authentication, + body.workspaceId, + "readWrite", + requestId, + instance + ); + + if (workspaceAccess instanceof Response) { + return workspaceAccess; + } + + const { organizationId, workspaceId } = workspaceAccess; + const userId = getSessionUserId(authentication); + const log = logger.withContext({ requestId, workspaceId, organizationId }); + + try { + assertV3SurveyGeneratePrompt(body.prompt); + // Hoisted out of streamOrganizationAIObject on purpose: an unentitled organization has to get a + // problem+json, not a 200 carrying an error event. + await assertOrganizationAIConfigured(organizationId); + } catch (error) { + return mapV3SurveyGenerateError(error, { requestId, instance, workspaceId, organizationId }); + } + + // Chained to req.signal (Next aborts that on client disconnect) but abortable by cancel() too, + // which can fire first and would otherwise leave the provider running to its 45s timeout. + const generationAbort = new AbortController(); + const abortGeneration = () => generationAbort.abort(); + // An abort that already happened is never replayed to a listener added afterwards, so a client + // that disconnected during the guards above would otherwise get a full generation billed to it. + if (req.signal.aborted) abortGeneration(); + else req.signal.addEventListener("abort", abortGeneration, { once: true }); + + let generation: TStreamObjectResult>; + try { + generation = await streamOrganizationAIObject({ + organizationId, + aiTracing: buildV3SurveyGenerationTracing({ workspaceId, userId }), + ...buildV3SurveyGenerationRequest(body), + // Next derives this from the client socket closing, so pressing Stop aborts the provider call + // itself rather than just detaching the reader — this is what stops the spend. + abortSignal: generationAbort.signal, + }); + } catch (error) { + return mapV3SurveyGenerateError(error, { requestId, instance, workspaceId, organizationId }); + } + + let closed = false; + + const stream = new ReadableStream({ + async start(controller) { + const emit = (event: TSurveyGenerationStreamEvent) => { + if (closed) return; + controller.enqueue(encodeStreamEvent(event)); + }; + + emit({ type: "start", requestId }); + + let seq = 0; + let lastEmittedAt: number | null = null; + let lastSerialized: string | null = null; + + try { + for await (const snapshot of generation.partialObjectStream) { + const serialized = JSON.stringify(snapshot); + if (!shouldEmitSnapshot({ now: Date.now(), lastEmittedAt, serialized, lastSerialized })) { + continue; + } + + seq += 1; + lastEmittedAt = Date.now(); + lastSerialized = serialized; + emit({ type: "partial", seq, draft: snapshot }); + } + + const draft = await generation.completion; + + // Always land the completed draft as the final snapshot, whatever the throttle said. Not the + // last partial: that one is a `DeepPartial` and can be missing fields the finished object + // has, and the review step renders this while saving uses the payload — so the two would + // disagree. It also covers a provider that streams no partials at all, where the review step + // would otherwise open on an empty list. + const finalDraft = draft as TSurveyGenerationDraftSnapshot; + const finalSerialized = JSON.stringify(finalDraft); + if (finalSerialized !== lastSerialized) { + seq += 1; + emit({ type: "partial", seq, draft: finalDraft }); + } + + const result = buildV3SurveyCreatePayloadFromDraft(body, draft); + emit({ type: "done", ...result }); + + if (userId) { + capturePostHogEvent( + userId, + "ai_survey_generated", + { prompt_length: body.prompt.length, streamed: true }, + { organizationId, workspaceId } + ); + } + } catch (error) { + if (isClientAbort(error, generationAbort.signal)) { + // A user pressing Stop is not an incident and must not page anyone. The socket is already + // gone, so there is nothing to tell them either. + log.info("AI survey generation aborted by the client"); + } else { + log.error({ err: error }, "AI survey generation stream failed"); + // Enqueue the error and close cleanly. controller.error() would truncate the response and + // the client would see a bare network failure with none of the code this event carries. + emit(toStreamErrorEvent(error)); + } + } finally { + req.signal.removeEventListener("abort", abortGeneration); + // close() throws on a stream the consumer already cancelled, and there is nobody left to + // tell either way. + if (!closed) { + closed = true; + controller.close(); + } + } + }, + cancel() { + // Next aborts its pipeTo on disconnect, which lands here — sometimes before the loop above + // observes req.signal. Mark the stream closed first so no in-flight emit enqueues into it. + closed = true; + abortGeneration(); + log.info("AI survey generation stream cancelled by the client"); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": SURVEY_GENERATION_STREAM_CONTENT_TYPE, + // no-transform is the RFC 9111 signal that forbids an intermediary coalescing or re-encoding + // the body; X-Accel-Buffering is for self-hosters fronting Formbricks with nginx-ingress, + // where proxy_buffering is on by default and would hold the whole response. + "Cache-Control": "no-cache, no-store, no-transform", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/apps/web/app/api/internal/surveys/generate/stream/route.ts b/apps/web/app/api/internal/surveys/generate/stream/route.ts new file mode 100644 index 000000000000..2f55d484b8f1 --- /dev/null +++ b/apps/web/app/api/internal/surveys/generate/stream/route.ts @@ -0,0 +1,43 @@ +/** + * POST /api/internal/surveys/generate/stream — stream a survey draft as the model writes it, so the + * create dialog can show questions appearing instead of a spinner. + * + * Internal surface: no OpenAPI entry and no stability promise, but every other v3 convention + * applies (ENG-1668 / the Internal API RFC). Deliberately *not* under /api/v3: the documented + * `POST /api/v3/surveys/generate` stays the stable, blocking, machine-facing endpoint, and enrolling + * an NDJSON stream in the Schemathesis contract suite would burn a live provider call per run to + * check a schema it cannot express. + * + * Session-only: an API-key caller already has the blocking endpoint, and there is no reason to hand + * a machine client a chunked, UI-shaped stream. The rate limit is deliberately the same bucket as + * the blocking route — the 10/min budget caps AI spend, and both entry points spend from it. + */ +import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; +import { ZV3SurveyGenerateBody } from "@/app/api/v3/surveys/generate/schemas"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { streamV3SurveyGeneration } from "../lib/operations"; + +// @formbricks/ai pulls the provider SDKs and posthog-node, none of which are edge-compatible. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + +export const POST = withV3ApiWrapper({ + auth: "session", + customRateLimitConfig: rateLimitConfigs.api.v3SurveyGenerate, + schemas: { + body: ZV3SurveyGenerateBody, + }, + // No action/targetType: nothing is persisted here (the survey row is written by the existing + // POST /api/v3/surveys, which audits already), and the wrapper derives auditLog.status from + // response.ok — which for a stream is true the instant we return, so a stream that later failed + // would log a success. + handler: async ({ req, authentication, parsedInput, requestId, instance }) => + streamV3SurveyGeneration({ + req, + authentication, + body: parsedInput.body, + requestId, + instance, + }), +}); diff --git a/apps/web/app/api/v3/surveys/generate/service.ts b/apps/web/app/api/v3/surveys/generate/service.ts index eebc3afa90bc..4f861fa14137 100644 --- a/apps/web/app/api/v3/surveys/generate/service.ts +++ b/apps/web/app/api/v3/surveys/generate/service.ts @@ -20,7 +20,7 @@ import { ZGeneratedSurveyDraftForAI, } from "./schemas"; -type TV3SurveyGenerateValidation = { +export type TV3SurveyGenerateValidation = { valid: boolean; invalid_params: InvalidParam[]; languages: Array<{ code: string; default: boolean; enabled: boolean }>; @@ -376,43 +376,71 @@ function serializeValidation( }; } -export async function generateV3SurveyCreatePayloadFromPrompt(params: { - organizationId: string; - workspaceId: string; - userId?: string | null; - input: TV3SurveyGenerateBody; -}): Promise { - const invalidParams = getPromptInvalidParams(params.input.prompt); +/** + * Throws when the prompt is too thin to generate from. Exported so the streaming route can run the + * same guard *before* it opens a response body — once a stream has begun, an RFC 9457 problem + * response is no longer possible. + */ +export function assertV3SurveyGeneratePrompt(prompt: string): void { + const invalidParams = getPromptInvalidParams(prompt); if (invalidParams.length > 0) { throw new V3SurveyGeneratePromptError(invalidParams); } +} - const generation = await generateOrganizationAIObject({ - organizationId: params.organizationId, - aiTracing: params.userId - ? { - distinctId: params.userId, - feature: AI_TRACING_FEATURE.SurveyGeneration, - workspaceId: params.workspaceId, - } - : undefined, +/** + * The exact model call both the blocking public route and the streaming internal route make. Every + * field is common to `TGenerateObjectOptions` and `TStreamObjectOptions`, so both spread it — which + * is what keeps a streamed draft identical to a blocking one rather than letting the two drift on a + * hand-copied temperature. + */ +export function buildV3SurveyGenerationRequest(input: TV3SurveyGenerateBody) { + return { + // Deliberately the *ForAI* schema (string ranges), not ZGeneratedSurveyDraft, which + // z.preprocess-coerces them to numbers. z.preprocess does not survive JSON-Schema conversion, + // so swapping the two here breaks provider structured output. schema: ZGeneratedSurveyDraftForAI, schemaName: "FormbricksSurveyDraft", schemaDescription: "A concise Formbricks survey draft that can be converted to a v3 create payload.", - system: buildV3SurveyGenerationSystemPrompt(V3_SURVEY_GENERATE_ALLOWED_LOCALES, params.input.type), + system: buildV3SurveyGenerationSystemPrompt(V3_SURVEY_GENERATE_ALLOWED_LOCALES, input.type), prompt: buildV3SurveyGenerationPrompt( - params.input.prompt, - params.input.type, - params.input.language ?? DEFAULT_V3_SURVEY_LANGUAGE, + input.prompt, + input.type, + input.language ?? DEFAULT_V3_SURVEY_LANGUAGE, V3_SURVEY_GENERATE_ALLOWED_LOCALES ), temperature: 0.2, maxOutputTokens: V3_SURVEY_GENERATION_MAX_OUTPUT_TOKENS, timeout: V3_SURVEY_GENERATION_TIMEOUT_MS, - }); + }; +} + +/** Tracing context, shared so both routes report under the same PostHog feature. */ +export function buildV3SurveyGenerationTracing(params: { workspaceId: string; userId?: string | null }) { + return params.userId + ? { + distinctId: params.userId, + feature: AI_TRACING_FEATURE.SurveyGeneration, + workspaceId: params.workspaceId, + } + : undefined; +} - const generatedSurvey = ZGeneratedSurveyDraft.safeParse(generation.object); +/** + * Converts a raw model draft into a v3 create payload. Pure and synchronous — no I/O, no AI call. + * Both the blocking and the streaming route converge here, so a regression in this function breaks + * the documented public endpoint as well as the prototype. + * + * @param draft the raw model object. Unvalidated by construction, hence the safeParse: a streamed + * partial can carry a half-written string or a value that is not yet a legal enum member, so only + * a terminal draft should ever reach this. + */ +export function buildV3SurveyCreatePayloadFromDraft( + input: TV3SurveyGenerateBody, + draft: unknown +): TV3SurveyGenerateResult { + const generatedSurvey = ZGeneratedSurveyDraft.safeParse(draft); if (!generatedSurvey.success) { throw new V3SurveyGeneratedPayloadValidationError( formatV3ZodInvalidParams(generatedSurvey.error, "generatedSurvey") @@ -420,7 +448,7 @@ export async function generateV3SurveyCreatePayloadFromPrompt(params: { } const normalizedGeneratedSurvey = normalizeGeneratedSurveyBlocks(generatedSurvey.data); - const createPayload = buildCreatePayload(params.input, normalizedGeneratedSurvey); + const createPayload = buildCreatePayload(input, normalizedGeneratedSurvey); const preparation = prepareV3SurveyCreateInput(createPayload); if (!preparation.ok) { throw new V3SurveyGeneratedPayloadValidationError(preparation.validation.invalidParams); @@ -432,3 +460,20 @@ export async function generateV3SurveyCreatePayloadFromPrompt(params: { validation: serializeValidation(preparation), }; } + +export async function generateV3SurveyCreatePayloadFromPrompt(params: { + organizationId: string; + workspaceId: string; + userId?: string | null; + input: TV3SurveyGenerateBody; +}): Promise { + assertV3SurveyGeneratePrompt(params.input.prompt); + + const generation = await generateOrganizationAIObject({ + organizationId: params.organizationId, + aiTracing: buildV3SurveyGenerationTracing(params), + ...buildV3SurveyGenerationRequest(params.input), + }); + + return buildV3SurveyCreatePayloadFromDraft(params.input, generation.object); +} diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 49d13df9292a..10e633eddf06 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2864,19 +2864,31 @@ checksums: workspace/surveys/ai_create/ai_not_enabled: 13df84ae47d35dfa6e86ffa62f29c75d workspace/surveys/ai_create/ai_not_in_plan: a059bf0be5d6ef524c1a4212b32a0c8b workspace/surveys/ai_create/ai_output_too_long: 3be34ddb7af610421d806827ae04f3d7 + workspace/surveys/ai_create/ai_rate_limited: fb5875814003cb27c15bbc30a20b9ec2 + workspace/surveys/ai_create/back_to_draft: 48e47c5d07a719e015da6ebeb903b195 workspace/surveys/ai_create/card_description: 3917eefc745d2bfec15d25ac343bbb64 workspace/surveys/ai_create/card_title: 5d8c659f559f1343bb23fd191a6556ad workspace/surveys/ai_create/characters: 94b5a8b5c869a7e9bc031f59de56de44 workspace/surveys/ai_create/choose_template: dc3f22a8178d5aef8bdac9d3fc7cc268 - workspace/surveys/ai_create/create: 757ccd28dd533ff3a933355273c1e32a + workspace/surveys/ai_create/create: 0345bf322c191e70d01fd6607ec5c2f8 workspace/surveys/ai_create/create_with_ai: 5d8c659f559f1343bb23fd191a6556ad workspace/surveys/ai_create/create_with_ai_description: 7aa3fe0237ead1066e01f3e1abd7c0ad - workspace/surveys/ai_create/creating: c949fe6aa47b326f345b405fe9c4d6e7 workspace/surveys/ai_create/dialog_description: 2c93d5a3152989234ed3819308037a53 workspace/surveys/ai_create/dialog_title: eefbf4e47f6df699e89f4996a5f925f8 + workspace/surveys/ai_create/discard: de83a114a79d086e372c43dbfe9f47b4 + workspace/surveys/ai_create/discard_draft_body: cb8f35c84717a502c223feee3dec0921 + workspace/surveys/ai_create/discard_draft_title: 8802ab9afc55d4ac30bc98eaad960847 + workspace/surveys/ai_create/discard_generation_body: 5a02c35831f3ce740aa9a266ac8998a1 + workspace/surveys/ai_create/discard_generation_title: 8c5317fdbafda4715e2eb4f983ec0b9c + workspace/surveys/ai_create/draft_survey: 20b562d5ee77ae9d1958e15d135c7130 + workspace/surveys/ai_create/edit_prompt: 789b263b56057a0c820437035e038990 workspace/surveys/ai_create/enable_ai_in_settings: f994107b44e64d8202446acb723740ac workspace/surveys/ai_create/generated_payload_invalid: 6b15c376fafd72298cb460107e82d4f2 - workspace/surveys/ai_create/opening_editor: 4c85b8b6dd78f09afea85939fab32612 + workspace/surveys/ai_create/generation_failed: cf68e9399104ba1ed48bd88a91d06270 + workspace/surveys/ai_create/keep_editing: 9778ce7d47a83d1d43b204fc177d2c4a + workspace/surveys/ai_create/nothing_generated: 4df9bd9e49c3b63cddd2cb4336e00420 + workspace/surveys/ai_create/open_in_editor: fa60853b0e075c7c134e4bb3c7c59d82 + workspace/surveys/ai_create/option_count: d80b988a57d3119484c140b046e91a8c workspace/surveys/ai_create/prompt_helper_churn: 0a12f5fcab212aaa357c4ebc32b81124 workspace/surveys/ai_create/prompt_helper_churn_label: be67c423d132ffdcf7447efe1602ee52 workspace/surveys/ai_create/prompt_helper_onboarding: 1a1918de97443b88b5aff9b4c4144421 @@ -2887,10 +2899,19 @@ checksums: workspace/surveys/ai_create/prompt_helper_website_label: 4f0df60ed6b5e65b61e060bd6447cf73 workspace/surveys/ai_create/prompt_label: 7d6d9969f13a0fb453cd63e239dec385 workspace/surveys/ai_create/prompt_placeholder: 75155ac1c57f46766b8e9c8531a9b25f - workspace/surveys/ai_create/shortcut_hint: f5a8984cb659c299fe1c93b2d40599ae + workspace/surveys/ai_create/regenerate: 79bc737effc16d0546c048c6aa2ec415 + workspace/surveys/ai_create/request_rejected: 9f2ae5777b9b02bdc7777963f2d45353 + workspace/surveys/ai_create/shortcut_hint: 9e726671a699dbd62e0adb05ec2d0c40 workspace/surveys/ai_create/start_from_scratch: 6fc756927ca9ea22c26368cccd64a67e + workspace/surveys/ai_create/status_planning: 692a7d090d72bea6debefc9e338e508f + workspace/surveys/ai_create/status_starting: db56f1a17081bd84efe020ed9e2c1b49 + workspace/surveys/ai_create/status_writing_questions: b7570299bb0b040fd3f9e6c05b193d3e + workspace/surveys/ai_create/status_writing_title: 36df5e366cbb6cc6e1c7e69e4eeb727f + workspace/surveys/ai_create/stop: 4c4b4d44708ba05fe8726cbc80122228 + workspace/surveys/ai_create/too_many_requests: 0f5c72e1ecbb6d3d7484f4ac55799c52 workspace/surveys/ai_create/try_prompt: 041af9478444924632c47c1cc5e6c14d workspace/surveys/ai_create/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693 + workspace/surveys/ai_create/your_prompt: 7fb03a18b3282ffa94e101e5c6924af2 workspace/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399 workspace/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee workspace/surveys/archive: fa813ab3074103e5daad07462af25789 diff --git a/apps/web/lib/ai/service.test.ts b/apps/web/lib/ai/service.test.ts index 829a29ac67bd..59cb14d42729 100644 --- a/apps/web/lib/ai/service.test.ts +++ b/apps/web/lib/ai/service.test.ts @@ -7,10 +7,12 @@ import { getAISmartToolsUnavailableReason, getOrganizationAIConfig, isInstanceAIConfigured, + streamOrganizationAIObject, } from "./service"; const mocks = vi.hoisted(() => ({ generateObject: vi.fn(), + streamObject: vi.fn(), generateText: vi.fn(), isAiConfigured: vi.fn(), classifyAIProviderError: vi.fn(), @@ -32,6 +34,7 @@ vi.mock("@formbricks/ai", () => ({ } }, generateObject: mocks.generateObject, + streamObject: mocks.streamObject, generateText: mocks.generateText, isAiConfigured: mocks.isAiConfigured, classifyAIProviderError: mocks.classifyAIProviderError, @@ -325,6 +328,68 @@ describe("AI organization service", () => { ).rejects.toBe(serverError); }); + describe("streamOrganizationAIObject", () => { + // Cast rather than `any`: `@formbricks/ai` is mocked here, so the schema is never read — but the + // input type still requires one. + const streamInput = () => + ({ organizationId: "org_1", prompt: "Generate", schema: { type: "object" } }) as unknown as Parameters< + typeof streamOrganizationAIObject + >[0]; + + const streamResult = (completion: Promise) => { + // The service hands this promise back untouched; keep it handled so a rejection asserted on + // later does not surface as an unhandled rejection first. + completion.catch(() => undefined); + return { partialObjectStream: {}, completion }; + }; + + test("a cancelled generation is not an error: no error log, and the rejection is untouched", async () => { + // Stop and tab-close both land here. Logging them at error level pages someone for a user + // doing exactly what the button offers. + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + mocks.streamObject.mockReturnValueOnce(streamResult(Promise.reject(abortError))); + + const result = await streamOrganizationAIObject(streamInput()); + + await expect(result.completion).rejects.toBe(abortError); + expect(mocks.loggerError).not.toHaveBeenCalled(); + expect(mocks.classifyAIProviderError).not.toHaveBeenCalled(); + }); + + test("an abort wrapped as a cause is recognised too", async () => { + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + const wrapped = new Error("stream failed", { cause: abortError }); + mocks.streamObject.mockReturnValueOnce(streamResult(Promise.reject(wrapped))); + + const result = await streamOrganizationAIObject(streamInput()); + + await expect(result.completion).rejects.toBe(wrapped); + expect(mocks.loggerError).not.toHaveBeenCalled(); + }); + + test("a real provider failure still logs and maps a 429", async () => { + const quotaError = new Error("Resource exhausted"); + mocks.classifyAIProviderError.mockReturnValueOnce({ + statusCode: 429, + isQuotaExhausted: true, + isRetryable: true, + retryAfterSeconds: 30, + }); + mocks.streamObject.mockReturnValueOnce(streamResult(Promise.reject(quotaError))); + + const result = await streamOrganizationAIObject(streamInput()); + + await expect(result.completion).rejects.toMatchObject({ + name: "TooManyRequestsError", + message: "ai_quota_exceeded", + retryAfter: 30, + }); + expect(mocks.loggerError).toHaveBeenCalled(); + }); + }); + describe("getAISmartToolsUnavailableReason", () => { const baseConfig = { organizationId: "org_1", diff --git a/apps/web/lib/ai/service.ts b/apps/web/lib/ai/service.ts index e07fb6ba8d06..394e0199a051 100644 --- a/apps/web/lib/ai/service.ts +++ b/apps/web/lib/ai/service.ts @@ -4,10 +4,13 @@ import { type AIResolvedLanguageModel, type TGenerateObjectOptions, type TGenerateObjectResult, + type TStreamObjectOptions, + type TStreamObjectResult, classifyAIProviderError, generateObject, generateText, isAiConfigured, + streamObject, } from "@formbricks/ai"; import { logger } from "@formbricks/logger"; import { @@ -38,6 +41,58 @@ export interface TOrganizationAIConfig { export const isInstanceAIConfigured = (): boolean => isAiConfigured(env); +/** + * A cancelled generation, as it reaches us: the fetch the provider is holding rejects with an + * `AbortError`, and the SDK sometimes hands it back wrapped one level down as the `cause`. + */ +/** + * The one place a provider failure is turned into a log line and, for a 429, a typed error. Shared + * by all three generation paths: they differ only in the log message, and drifting on which fields + * get logged — or on whether a cancellation is exempt — is exactly how one path ends up paging + * someone for a user pressing Stop. + */ +// A function declaration, not an arrow const: TypeScript only treats a call as terminating — so the +// catch blocks below need no unreachable `throw` after it — when the callee is declared this way. +function classifyOrganizationAIFailure( + error: unknown, + { + organizationId, + aiConfig, + message, + }: { organizationId: string; aiConfig: TOrganizationAIConfig; message: string } +): never { + // A cancelled generation is the user pressing Stop or closing the tab, not an incident: it must + // not be logged at error level and it carries no provider status to map. + if (isAbortError(error)) throw error; + + const providerError = classifyAIProviderError(error); + logger.error( + { + organizationId, + isInstanceConfigured: aiConfig.isInstanceConfigured, + errorCode: error instanceof AIConfigurationError ? error.code : undefined, + statusCode: providerError?.statusCode, + isQuotaExhausted: providerError?.isQuotaExhausted, + isRetryable: providerError?.isRetryable, + err: error, + }, + message + ); + + if (providerError?.isQuotaExhausted) { + throw new TooManyRequestsError(AI_ERROR_CODES.QUOTA_EXCEEDED, providerError.retryAfterSeconds); + } + + throw error; +} + +const isAbortError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + if (error.name === "AbortError") return true; + + return error.cause instanceof Error && error.cause.name === "AbortError"; +}; + export const getOrganizationAIConfig = async (organizationId: string): Promise => { const organization = await getOrganization(organizationId); @@ -105,23 +160,11 @@ export const generateOrganizationAIText = async ({ try { return await generateText(options, env, wrapModel); } catch (error) { - const providerError = classifyAIProviderError(error); - logger.error( - { - organizationId, - isInstanceConfigured: aiConfig.isInstanceConfigured, - errorCode: error instanceof AIConfigurationError ? error.code : undefined, - statusCode: providerError?.statusCode, - isQuotaExhausted: providerError?.isQuotaExhausted, - isRetryable: providerError?.isRetryable, - err: error, - }, - "Failed to generate organization AI text" - ); - if (providerError?.isQuotaExhausted) { - throw new TooManyRequestsError(AI_ERROR_CODES.QUOTA_EXCEEDED, providerError.retryAfterSeconds); - } - throw error; + classifyOrganizationAIFailure(error, { + organizationId, + aiConfig, + message: "Failed to generate organization AI text", + }); } }; @@ -144,22 +187,58 @@ export const generateOrganizationAIObject = async ({ try { return await generateObject(options, env, wrapModel); } catch (error) { - const providerError = classifyAIProviderError(error); - logger.error( - { - organizationId, - isInstanceConfigured: aiConfig.isInstanceConfigured, - errorCode: error instanceof AIConfigurationError ? error.code : undefined, - statusCode: providerError?.statusCode, - isQuotaExhausted: providerError?.isQuotaExhausted, - isRetryable: providerError?.isRetryable, - err: error, - }, - "Failed to generate organization AI object" - ); - if (providerError?.isQuotaExhausted) { - throw new TooManyRequestsError(AI_ERROR_CODES.QUOTA_EXCEEDED, providerError.retryAfterSeconds); - } - throw error; + classifyOrganizationAIFailure(error, { + organizationId, + aiConfig, + message: "Failed to generate organization AI object", + }); + } +}; + +type TStreamOrganizationAIObjectInput = { + organizationId: string; + aiTracing?: Omit; +} & TStreamObjectOptions; + +/** + * Streaming counterpart to `generateOrganizationAIObject`, with the same entitlement, tracing and + * quota-classification contract. + * + * Note the two catch surfaces. `streamObject` returns before the provider has been called, so the + * try/catch below only ever sees the synchronous `AIConfigurationError` from model resolution; + * everything the blocking sibling's catch handles — provider failures, 429s — arrives later on + * `completion` and needs its own handler. Collapsing these into one means the quota mapping never + * fires for a streamed generation. + */ +export const streamOrganizationAIObject = async ({ + organizationId, + aiTracing, + ...options +}: TStreamOrganizationAIObjectInput): Promise> => { + const aiConfig = await assertOrganizationAIConfigured(organizationId); + + const wrapModel = aiTracing + ? (model: AIResolvedLanguageModel) => wrapAiModelWithTracing(model, { organizationId, ...aiTracing }) + : undefined; + + const classify = (error: unknown): never => + classifyOrganizationAIFailure(error, { + organizationId, + aiConfig, + message: "Failed to stream organization AI object", + }); + + try { + const result = streamObject(options, env, wrapModel); + const completion = result.completion.catch(classify); + // The caller may only consume the partial stream (client aborted); keep the classified + // rejection from surfacing as an unhandled one. + completion.catch(() => undefined); + + // Enumerated rather than spread: a future lazy getter on the result would be evaluated by a + // spread, draining the base stream as a side effect. + return { partialObjectStream: result.partialObjectStream, completion }; + } catch (error) { + return classify(error); } }; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index d3118bfc524d..5832bcf98f58 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "KI-Smart-Tools sind für diese Organisation deaktiviert.", "ai_not_in_plan": "KI-Umfragenerstellung ist in deinem aktuellen Tarif nicht verfügbar.", "ai_output_too_long": "Diese Eingabe fordert mehr an, als die KI auf einmal generieren kann. Vereinfache sie oder teile sie in kleinere Umfragen auf.", + "ai_rate_limited": "Der KI-Anbieter ist gerade ausgelastet. Warte einen Moment und versuch es nochmal.", + "back_to_draft": "Zurück zum Entwurf", "card_description": "Beschreibe, was du herausfinden möchtest, und erstelle einen Umfrage-Entwurf.", "card_title": "Mit KI erstellen", "characters": "{count}/{max}", "choose_template": "Wähle eine Vorlage", - "create": "Erstellen", + "create": "Generieren", "create_with_ai": "Mit KI erstellen", "create_with_ai_description": "Beschreibe, was du brauchst, und erhalte einen Umfrage-Entwurf.", - "creating": "Wird erstellt...", "dialog_description": "Formbricks erstellt einen Entwurf, den du vor der Veröffentlichung bearbeiten kannst.", "dialog_title": "Erstelle deine Umfrage mit KI", + "discard": "Verwerfen", + "discard_draft_body": "Dein generierter Entwurf wurde noch nicht im Editor geöffnet. Beim Schließen wird er verworfen.", + "discard_draft_title": "Diesen Entwurf verwerfen?", + "discard_generation_body": "Die Umfrage wird gerade noch generiert. Beim Schließen wird der Vorgang gestoppt und das bereits Geschriebene verworfen.", + "discard_generation_title": "Generierung stoppen?", + "draft_survey": "Umfrage-Entwurf", + "edit_prompt": "Eingabe bearbeiten", "enable_ai_in_settings": "In den Einstellungen aktivieren", "generated_payload_invalid": "Der Umfrageentwurf konnte nicht validiert werden. Versuche, mehr Details hinzuzufügen.", - "opening_editor": "Editor wird geöffnet...", + "generation_failed": "Der Entwurf konnte nicht fertiggestellt werden. Versuch es nochmal oder füge etwas mehr Details hinzu.", + "keep_editing": "Weiter bearbeiten", + "nothing_generated": "Es wurden keine Fragen erstellt. Versuch die Umfrage etwas detaillierter zu beschreiben.", + "open_in_editor": "Speichern und fortfahren", + "option_count": "{count, plural, one {# Option} other {# Optionen}}", "prompt_helper_churn": "Verstehe, warum aktive Kunden abwandern könnten und was sie bei der Stange halten würde", "prompt_helper_churn_label": "Abwanderungsrisiko", "prompt_helper_onboarding": "Finde heraus, warum neue Nutzer während des Onboardings abbrechen und was ihnen helfen würde, die Einrichtung abzuschließen", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Website-Feedback", "prompt_label": "Beschreibe die Umfrage, die du brauchst", "prompt_placeholder": "Beschreibe die Umfrage, die du benötigst, z. B. Verstehen, warum neue Nutzer während des Onboardings abbrechen und was ihnen helfen würde, die Einrichtung abzuschließen", - "shortcut_hint": "⌘/Strg + Enter zum Erstellen", + "regenerate": "Neu generieren", + "request_rejected": "Diese Eingabe konnte nicht verwendet werden. Versuche, sie umzuformulieren.", + "shortcut_hint": "⌘/Strg + Eingabetaste zum Generieren", "start_from_scratch": "Von vorne beginnen", + "status_planning": "Plane die Umfrage…", + "status_starting": "Lese deine Eingabe…", + "status_writing_questions": "Schreibe Frage {count}…", + "status_writing_title": "Benenne deine Umfrage…", + "stop": "Stopp", + "too_many_requests": "Du generierst zu schnell. Warte einen Moment und versuche es erneut.", "try_prompt": "Beispiel-Prompt ansehen", - "upgrade_plan": "Tarif upgraden" + "upgrade_plan": "Tarif upgraden", + "your_prompt": "Deine Eingabe" }, "all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen", "alphabetical": "alphabetisch", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 48c87c28b57f..f9e2636895cf 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "AI smart tools are disabled for this organization.", "ai_not_in_plan": "AI survey creation is not available on your current plan.", "ai_output_too_long": "This prompt asks for more than the AI can generate in one go. Simplify it or split it into smaller surveys.", + "ai_rate_limited": "The AI provider is busy right now. Wait a moment and try again.", + "back_to_draft": "Back to draft", "card_description": "Describe what you want to learn and create a draft survey.", "card_title": "Create with AI", "characters": "{count}/{max}", "choose_template": "Choose a template", - "create": "Create", + "create": "Generate", "create_with_ai": "Create with AI", "create_with_ai_description": "Describe what you need and get a draft survey.", - "creating": "Creating...", "dialog_description": "Formbricks will create a draft you can edit before publishing.", "dialog_title": "Create your survey using AI", + "discard": "Discard", + "discard_draft_body": "Your generated draft has not been opened in the editor yet. Closing will discard it.", + "discard_draft_title": "Discard this draft?", + "discard_generation_body": "The survey is still being generated. Closing will stop it and discard what has been written.", + "discard_generation_title": "Stop generating?", + "draft_survey": "Draft survey", + "edit_prompt": "Edit prompt", "enable_ai_in_settings": "Enable in settings", "generated_payload_invalid": "The survey draft could not be validated. Try adding more detail.", - "opening_editor": "Opening editor...", + "generation_failed": "The draft could not be finished. Try again, or add a bit more detail.", + "keep_editing": "Keep editing", + "nothing_generated": "No questions came back. Try describing the survey in a bit more detail.", + "open_in_editor": "Save and continue", + "option_count": "{count, plural, one {# option} other {# options}}", "prompt_helper_churn": "Understand why active customers may churn and what would keep them engaged", "prompt_helper_churn_label": "Churn risk", "prompt_helper_onboarding": "Find out why new users stop during onboarding and what would help them finish setup", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Website feedback", "prompt_label": "Describe the survey you need", "prompt_placeholder": "Describe the survey you need, e.g. Understand why new users stop during onboarding and what would help them finish setup", - "shortcut_hint": "⌘/Ctrl + Enter to create", + "regenerate": "Regenerate", + "request_rejected": "That prompt could not be used. Try rephrasing it.", + "shortcut_hint": "⌘/Ctrl + Enter to generate", "start_from_scratch": "Start from scratch", + "status_planning": "Planning the survey…", + "status_starting": "Reading your prompt…", + "status_writing_questions": "Writing question {count}…", + "status_writing_title": "Naming your survey…", + "stop": "Stop", + "too_many_requests": "You are generating too quickly. Wait a moment and try again.", "try_prompt": "See example prompt", - "upgrade_plan": "Upgrade plan" + "upgrade_plan": "Upgrade plan", + "your_prompt": "Your prompt" }, "all_set_time_to_create_first_survey": "You are all set! Time to create your first survey", "alphabetical": "Alphabetical", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 720c4ed4efe0..483b691a35df 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "Las herramientas inteligentes de IA están desactivadas para esta organización.", "ai_not_in_plan": "La creación de encuestas con IA no está disponible en tu plan actual.", "ai_output_too_long": "Este prompt solicita más de lo que la IA puede generar de una sola vez. Simplifícalo o divídelo en encuestas más pequeñas.", + "ai_rate_limited": "El proveedor de IA está ocupado ahora mismo. Espera un momento e inténtalo de nuevo.", + "back_to_draft": "Volver al borrador", "card_description": "Describe lo que quieres aprender y crea un borrador de encuesta.", "card_title": "Crear con IA", "characters": "{count}/{max}", "choose_template": "Elige una plantilla", - "create": "Crear", + "create": "Generar", "create_with_ai": "Crear con IA", "create_with_ai_description": "Describe lo que necesitas y obtén un borrador de encuesta.", - "creating": "Creando...", "dialog_description": "Formbricks creará un borrador que podrás editar antes de publicar.", "dialog_title": "Crea tu encuesta con IA", + "discard": "Descartar", + "discard_draft_body": "Tu borrador generado aún no se ha abierto en el editor. Si cierras ahora, se descartará.", + "discard_draft_title": "¿Descartar este borrador?", + "discard_generation_body": "La encuesta todavía se está generando. Si cierras ahora, se detendrá y se descartará lo que se ha escrito.", + "discard_generation_title": "¿Detener la generación?", + "draft_survey": "Borrador de encuesta", + "edit_prompt": "Editar indicación", "enable_ai_in_settings": "Activar en ajustes", "generated_payload_invalid": "El borrador de la encuesta no pudo validarse. Intenta añadir más detalles.", - "opening_editor": "Abriendo editor...", + "generation_failed": "No se pudo finalizar el borrador. Inténtalo de nuevo o añade un poco más de detalle.", + "keep_editing": "Seguir editando", + "nothing_generated": "No se generaron preguntas. Intenta describir la encuesta con un poco más de detalle.", + "open_in_editor": "Guardar y continuar", + "option_count": "{count, plural, one {# opción} other {# opciones}}", "prompt_helper_churn": "Entiende por qué los clientes activos podrían abandonar y qué les mantendría comprometidos", "prompt_helper_churn_label": "Riesgo de abandono", "prompt_helper_onboarding": "Descubre por qué los nuevos usuarios abandonan durante el onboarding y qué les ayudaría a completar la configuración", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Feedback del sitio web", "prompt_label": "Describe la encuesta que necesitas", "prompt_placeholder": "Describe la encuesta que necesitas, por ejemplo: Entender por qué los nuevos usuarios abandonan durante la incorporación y qué les ayudaría a completar la configuración", - "shortcut_hint": "⌘/Ctrl + Enter para crear", + "regenerate": "Regenerar", + "request_rejected": "No se pudo usar ese mensaje. Intenta reformularlo.", + "shortcut_hint": "⌘/Ctrl + Intro para generar", "start_from_scratch": "Empezar desde cero", + "status_planning": "Planificando la encuesta…", + "status_starting": "Leyendo tu indicación…", + "status_writing_questions": "Escribiendo la pregunta {count}…", + "status_writing_title": "Nombrando tu encuesta…", + "stop": "Detener", + "too_many_requests": "Estás generando demasiado rápido. Espera un momento e inténtalo de nuevo.", "try_prompt": "Ver ejemplo de instrucción", - "upgrade_plan": "Mejorar plan" + "upgrade_plan": "Mejorar plan", + "your_prompt": "Tu indicación" }, "all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta", "alphabetical": "Alfabético", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 42bb966a3671..62ae0bc9a0d8 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "Les outils intelligents IA sont désactivés pour cette organisation.", "ai_not_in_plan": "La création de questionnaires par IA n'est pas disponible avec ton forfait actuel.", "ai_output_too_long": "Cette instruction demande plus que ce que l'IA peut générer en une seule fois. Simplifie-la ou divise-la en plusieurs questionnaires.", + "ai_rate_limited": "Le fournisseur d'IA est occupé pour le moment. Attends un instant et réessaie.", + "back_to_draft": "Retour au brouillon", "card_description": "Décris ce que tu veux apprendre et crée un brouillon de questionnaire.", "card_title": "Créer avec l'IA", "characters": "{count}/{max}", "choose_template": "Choisis un modèle", - "create": "Créer", + "create": "Générer", "create_with_ai": "Créer avec l'IA", "create_with_ai_description": "Décris ce dont tu as besoin et obtiens un brouillon de questionnaire.", - "creating": "Création...", "dialog_description": "Formbricks créera un brouillon que tu pourras modifier avant de publier.", "dialog_title": "Crée ton enquête avec l'IA", + "discard": "Annuler", + "discard_draft_body": "Ton brouillon généré n'a pas encore été ouvert dans l'éditeur. Le fermer le supprimera.", + "discard_draft_title": "Supprimer ce brouillon ?", + "discard_generation_body": "Le questionnaire est encore en cours de génération. Le fermer arrêtera la génération et supprimera ce qui a été écrit.", + "discard_generation_title": "Arrêter la génération ?", + "draft_survey": "Brouillon de sondage", + "edit_prompt": "Modifier l'invite", "enable_ai_in_settings": "Activer dans les paramètres", "generated_payload_invalid": "Le brouillon du questionnaire n'a pas pu être validé. Essaie d'ajouter plus de détails.", - "opening_editor": "Ouverture de l'éditeur...", + "generation_failed": "Le brouillon n'a pas pu être terminé. Réessaie ou ajoute un peu plus de détails.", + "keep_editing": "Continuer à modifier", + "nothing_generated": "Aucune question n'a été générée. Essaie de décrire le sondage avec un peu plus de détails.", + "open_in_editor": "Enregistrer et continuer", + "option_count": "{count, plural, one {# option} other {# options}}", "prompt_helper_churn": "Comprendre pourquoi les clients actifs pourraient partir et ce qui les maintiendrait engagés", "prompt_helper_churn_label": "Risque d'attrition", "prompt_helper_onboarding": "Découvre pourquoi les nouveaux utilisateurs abandonnent pendant l'onboarding et ce qui les aiderait à terminer la configuration", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Retours sur le site web", "prompt_label": "Décris le questionnaire dont tu as besoin", "prompt_placeholder": "Décris l'enquête dont tu as besoin, par exemple : Comprendre pourquoi les nouveaux utilisateurs abandonnent pendant l'onboarding et ce qui les aiderait à terminer la configuration", - "shortcut_hint": "⌘/Ctrl + Entrée pour créer", + "regenerate": "Régénérer", + "request_rejected": "Cette demande n'a pas pu être utilisée. Essaie de la reformuler.", + "shortcut_hint": "⌘/Ctrl + Entrée pour générer", "start_from_scratch": "Partir de zéro", + "status_planning": "Planification du sondage…", + "status_starting": "Lecture de ton invite…", + "status_writing_questions": "Rédaction de la question {count}…", + "status_writing_title": "Attribution d'un nom à ton sondage…", + "stop": "Arrêter", + "too_many_requests": "Tu génères trop rapidement. Attends un instant et réessaie.", "try_prompt": "Voir un exemple d'instruction", - "upgrade_plan": "Mettre à niveau l'abonnement" + "upgrade_plan": "Mettre à niveau l'abonnement", + "your_prompt": "Ton invite" }, "all_set_time_to_create_first_survey": "Vous êtes prêt ! Il est temps de créer votre première enquête.", "alphabetical": "Alphabétique", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 221470e909f8..dc63afdb3fc9 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.", "ai_not_in_plan": "Az AI kérdőív-készítés nem elérhető az Ön jelenlegi csomagjában.", "ai_output_too_long": "Ez a felkérés többet kér, mint amennyit a mesterséges intelligencia egyszerre képes generálni. Kérem, egyszerűsítse le, vagy ossza fel kisebb felmérésekre.", + "ai_rate_limited": "Az AI szolgáltató jelenleg leterhelt. Várjon egy pillanatot, majd próbálja újra.", + "back_to_draft": "Vissza a piszkozathoz", "card_description": "Írja le, hogy mit szeretne megtudni, és hozzon létre egy kérdőívtervezetet.", "card_title": "Készítsen AI-val", "characters": "{count}/{max}", "choose_template": "Válasszon sablont", - "create": "Létrehozás", + "create": "Generálás", "create_with_ai": "Létrehozás AI-val", "create_with_ai_description": "Írja le, mire van szüksége, és kapjon egy kérdőív-vázlatot.", - "creating": "Létrehozás folyamatban...", "dialog_description": "A Formbricks készít egy vázlatot, amelyet közzététel előtt szerkeszthet.", "dialog_title": "Hozza létre felmérését mesterséges intelligencia használatával", + "discard": "Elvetés", + "discard_draft_body": "Az elkészített piszkozat még nem lett megnyitva a szerkesztőben. A bezárás el fogja vetni.", + "discard_draft_title": "Elveti ezt a piszkozatot?", + "discard_generation_body": "A felmérés még generálás alatt áll. A bezárás leállítja és elveti az eddig leírtakat.", + "discard_generation_title": "Leállítja a generálást?", + "draft_survey": "Kérdőív piszkozat", + "edit_prompt": "Utasítás szerkesztése", "enable_ai_in_settings": "Engedélyezze a beállításokban", "generated_payload_invalid": "A kérdőívvázlatot nem sikerült érvényesíteni. Próbálj meg több részletet hozzáadni.", - "opening_editor": "Szerkesztő megnyitása...", + "generation_failed": "A piszkozat nem fejeződhetett be. Próbálja újra, vagy adjon meg valamivel több részletet.", + "keep_editing": "Szerkesztés folytatása", + "nothing_generated": "Nem érkezett vissza egyetlen kérdés sem. Próbálja meg valamivel részletesebben leírni a kérdőívet.", + "open_in_editor": "Mentés és folytatás", + "option_count": "{count, plural, one {# lehetőség} other {# lehetőség}}", "prompt_helper_churn": "Értse meg, hogy az aktív ügyfelek miért hagyhatják el a szolgáltatást, és mi tartaná meg őket elkötelezetten", "prompt_helper_churn_label": "Lemorzsolódási kockázat", "prompt_helper_onboarding": "Derítsd ki, miért hagyják abba az új felhasználók a regisztrációt, és mi segítené őket a beállítás befejezésében", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Weboldal visszajelzés", "prompt_label": "Írd le, milyen kérdőívre van szükséged", "prompt_placeholder": "Írja le, milyen felmérésre van szüksége, például: Annak megértése, hogy az új felhasználók miért hagyják abba a regisztrációt, és mi segíthetne nekik a beállítás befejezésében", - "shortcut_hint": "⌘/Ctrl + Enter a létrehozáshoz", + "regenerate": "Újragenerálás", + "request_rejected": "Ez a parancs nem használható. Kérem, fogalmazza meg másképp.", + "shortcut_hint": "⌘/Ctrl + Enter a generáláshoz", "start_from_scratch": "Kezdés a nulláról", + "status_planning": "A kérdőív tervezése…", + "status_starting": "Az Ön utasításának olvasása…", + "status_writing_questions": "{count}. kérdés írása…", + "status_writing_title": "A kérdőív elnevezése…", + "stop": "Leállítás", + "too_many_requests": "Túl gyorsan generál. Kérem, várjon egy pillanatot, és próbálja újra.", "try_prompt": "Példa promptra", - "upgrade_plan": "Csomag frissítése" + "upgrade_plan": "Csomag frissítése", + "your_prompt": "Az Ön utasítása" }, "all_set_time_to_create_first_survey": "Mindent beállított! Ideje létrehozni az első kérdőívet", "alphabetical": "Ábécé-sorrend", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 7c30a67b0aea..9301e5251fc3 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "この組織ではAIスマートツールが無効になっています。", "ai_not_in_plan": "現在のプランではAIによるアンケート作成は利用できません。", "ai_output_too_long": "このプロンプトは、AIが一度に生成できる量を超えています。プロンプトを簡潔にするか、複数のアンケートに分割してください。", + "ai_rate_limited": "AIプロバイダーが現在混雑しています。しばらく待ってから再度お試しください。", + "back_to_draft": "下書きに戻る", "card_description": "知りたいことを説明するだけで、アンケートの下書きを作成できます。", "card_title": "AIで作成", "characters": "{count}/{max}", "choose_template": "テンプレートを選択", - "create": "作成", + "create": "生成", "create_with_ai": "AIで作成", "create_with_ai_description": "必要な内容を説明すると、アンケートの下書きが作成されます。", - "creating": "作成中...", "dialog_description": "Formbricksが下書きを作成します。公開前に編集できます。", "dialog_title": "AIを使ってアンケートを作成", + "discard": "破棄", + "discard_draft_body": "生成された下書きはまだエディターで開かれていません。閉じると破棄されます。", + "discard_draft_title": "この下書きを破棄しますか?", + "discard_generation_body": "アンケートはまだ生成中です。閉じると生成が停止され、作成された内容が破棄されます。", + "discard_generation_title": "生成を停止しますか?", + "draft_survey": "下書きフォーム", + "edit_prompt": "プロンプトを編集", "enable_ai_in_settings": "設定で有効にする", "generated_payload_invalid": "アンケートの下書きを検証できませんでした。詳細を追加してみてください。", - "opening_editor": "エディターを開いています...", + "generation_failed": "下書きを完成できませんでした。もう一度試すか、詳細を少し追加してください。", + "keep_editing": "編集を続ける", + "nothing_generated": "質問が生成されませんでした。フォームについてもう少し詳しく説明してみてください。", + "open_in_editor": "保存して続ける", + "option_count": "{count, plural, other {#個のオプション}}", "prompt_helper_churn": "アクティブな顧客が離脱する理由と、顧客のエンゲージメントを維持するために必要なことを理解する", "prompt_helper_churn_label": "解約リスク", "prompt_helper_onboarding": "新規ユーザーがオンボーディング中に離脱する理由と、セットアップを完了するために必要なサポートを調査", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "ウェブサイトフィードバック", "prompt_label": "必要なアンケートを説明", "prompt_placeholder": "必要なアンケートを説明してください。例:新規ユーザーがオンボーディング中に離脱する理由と、セットアップ完了を支援する方法を理解する", - "shortcut_hint": "⌘/Ctrl + Enterで作成", + "regenerate": "再生成", + "request_rejected": "そのプロンプトは使用できませんでした。言い換えてみてください。", + "shortcut_hint": "⌘/Ctrl + Enterで生成", "start_from_scratch": "ゼロから作成", + "status_planning": "フォームを計画中…", + "status_starting": "プロンプトを読み取り中…", + "status_writing_questions": "質問{count}を作成中…", + "status_writing_title": "フォーム名を設定中…", + "stop": "停止", + "too_many_requests": "生成が速すぎます。少し待ってからもう一度お試しください。", "try_prompt": "プロンプト例を見る", - "upgrade_plan": "プランをアップグレード" + "upgrade_plan": "プランをアップグレード", + "your_prompt": "あなたのプロンプト" }, "all_set_time_to_create_first_survey": "すべての準備が整いました!最初のフォームを作成しましょう", "alphabetical": "アルファベット順", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index e4b9139f8b92..99d78e3079ee 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "AI slimme tools zijn uitgeschakeld voor deze organisatie.", "ai_not_in_plan": "AI-enquêtecreatie is niet beschikbaar in je huidige abonnement.", "ai_output_too_long": "Deze prompt vraagt om meer dan de AI in één keer kan genereren. Vereenvoudig het of splits het op in kleinere enquêtes.", + "ai_rate_limited": "De AI-provider is momenteel druk bezig. Wacht even en probeer het opnieuw.", + "back_to_draft": "Terug naar concept", "card_description": "Beschrijf wat je wilt leren en maak een conceptenquête.", "card_title": "Maak met AI", "characters": "{count}/{max}", "choose_template": "Kies een sjabloon", - "create": "Aanmaken", + "create": "Genereren", "create_with_ai": "Aanmaken met AI", "create_with_ai_description": "Beschrijf wat je nodig hebt en ontvang een conceptenquête.", - "creating": "Aanmaken...", "dialog_description": "Formbricks maakt een concept dat je kunt bewerken voordat je publiceert.", "dialog_title": "Maak je enquête met AI", + "discard": "Weggooien", + "discard_draft_body": "Je gegenereerde concept is nog niet geopend in de editor. Sluiten zal het weggooien.", + "discard_draft_title": "Dit concept weggooien?", + "discard_generation_body": "De enquête wordt nog gegenereerd. Sluiten zal het stoppen en weggooien wat er geschreven is.", + "discard_generation_title": "Stoppen met genereren?", + "draft_survey": "Conceptenquête", + "edit_prompt": "Prompt bewerken", "enable_ai_in_settings": "Inschakelen in instellingen", "generated_payload_invalid": "Het enquêteconcept kon niet worden gevalideerd. Probeer meer details toe te voegen.", - "opening_editor": "Editor openen...", + "generation_failed": "Het concept kon niet worden afgemaakt. Probeer het opnieuw of voeg wat meer details toe.", + "keep_editing": "Blijf bewerken", + "nothing_generated": "Er zijn geen vragen teruggekomen. Probeer de enquête wat gedetailleerder te beschrijven.", + "open_in_editor": "Opslaan en doorgaan", + "option_count": "{count, plural, one {# optie} other {# opties}}", "prompt_helper_churn": "Begrijp waarom actieve klanten mogelijk afhaken en wat hen betrokken zou houden", "prompt_helper_churn_label": "Klantverloop risico", "prompt_helper_onboarding": "Ontdek waarom nieuwe gebruikers afhaken tijdens onboarding en wat hen zou helpen om de setup af te ronden", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Website feedback", "prompt_label": "Beschrijf de enquête die je nodig hebt", "prompt_placeholder": "Beschrijf de enquête die je nodig hebt, bijv. Begrijpen waarom nieuwe gebruikers stoppen tijdens de onboarding en wat hen zou helpen de setup af te ronden", - "shortcut_hint": "⌘/Ctrl + Enter om aan te maken", + "regenerate": "Opnieuw genereren", + "request_rejected": "Die prompt kan niet worden gebruikt. Probeer het anders te formuleren.", + "shortcut_hint": "⌘/Ctrl + Enter om te genereren", "start_from_scratch": "Begin vanaf nul", + "status_planning": "Enquête plannen…", + "status_starting": "Je prompt lezen…", + "status_writing_questions": "Vraag {count} schrijven…", + "status_writing_title": "Je enquête een naam geven…", + "stop": "Stop", + "too_many_requests": "Je genereert te snel. Wacht even en probeer het opnieuw.", "try_prompt": "Bekijk voorbeeldprompt", - "upgrade_plan": "Upgrade abonnement" + "upgrade_plan": "Upgrade abonnement", + "your_prompt": "Jouw prompt" }, "all_set_time_to_create_first_survey": "Je bent helemaal klaar! Tijd om uw eerste enquête te maken", "alphabetical": "Alfabetisch", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 39cbb06be199..ec635f3136e5 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "As ferramentas inteligentes de IA estão desabilitadas para esta organização.", "ai_not_in_plan": "A criação de pesquisas com IA não está disponível no seu plano atual.", "ai_output_too_long": "Este prompt solicita mais do que a IA pode gerar de uma vez. Simplifique-o ou divida em pesquisas menores.", + "ai_rate_limited": "O provedor de IA está ocupado no momento. Aguarde um instante e tente novamente.", + "back_to_draft": "Voltar ao rascunho", "card_description": "Descreva o que você quer aprender e crie um rascunho de pesquisa.", "card_title": "Criar com IA", "characters": "{count}/{max}", "choose_template": "Escolha um modelo", - "create": "Criar", + "create": "Gerar", "create_with_ai": "Criar com IA", "create_with_ai_description": "Descreva o que você precisa e receba um rascunho de pesquisa.", - "creating": "Criando...", "dialog_description": "O Formbricks criará um rascunho que você pode editar antes de publicar.", "dialog_title": "Crie sua pesquisa usando IA", + "discard": "Descartar", + "discard_draft_body": "Seu rascunho gerado ainda não foi aberto no editor. Fechar irá descartá-lo.", + "discard_draft_title": "Descartar este rascunho?", + "discard_generation_body": "A pesquisa ainda está sendo gerada. Fechar irá interromper e descartar o que foi escrito.", + "discard_generation_title": "Parar de gerar?", + "draft_survey": "Rascunho da pesquisa", + "edit_prompt": "Editar prompt", "enable_ai_in_settings": "Habilitar nas configurações", "generated_payload_invalid": "O rascunho da pesquisa não pôde ser validado. Tente adicionar mais detalhes.", - "opening_editor": "Abrindo o editor...", + "generation_failed": "Não foi possível concluir o rascunho. Tente novamente ou adicione mais detalhes.", + "keep_editing": "Continuar editando", + "nothing_generated": "Nenhuma pergunta foi gerada. Tente descrever a pesquisa com mais detalhes.", + "open_in_editor": "Salvar e continuar", + "option_count": "{count, plural, one {# opção} other {# opções}}", "prompt_helper_churn": "Entenda por que clientes ativos podem cancelar e o que os manteria engajados", "prompt_helper_churn_label": "Risco de cancelamento", "prompt_helper_onboarding": "Descubra por que novos usuários abandonam durante a integração e o que os ajudaria a concluir a configuração", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Feedback do site", "prompt_label": "Descreva a pesquisa que você precisa", "prompt_placeholder": "Descreva a pesquisa que você precisa, por exemplo: Entender por que novos usuários abandonam durante a integração e o que os ajudaria a concluir a configuração", - "shortcut_hint": "⌘/Ctrl + Enter para criar", + "regenerate": "Gerar novamente", + "request_rejected": "Esse prompt não pôde ser usado. Tente reformulá-lo.", + "shortcut_hint": "⌘/Ctrl + Enter para gerar", "start_from_scratch": "Começar do zero", + "status_planning": "Planejando a pesquisa…", + "status_starting": "Lendo seu prompt…", + "status_writing_questions": "Escrevendo pergunta {count}…", + "status_writing_title": "Nomeando sua pesquisa…", + "stop": "Parar", + "too_many_requests": "Você está gerando rápido demais. Aguarde um momento e tente novamente.", "try_prompt": "Ver exemplo de prompt", - "upgrade_plan": "Fazer upgrade do plano" + "upgrade_plan": "Fazer upgrade do plano", + "your_prompt": "Seu prompt" }, "all_set_time_to_create_first_survey": "Tá tudo pronto! Hora de criar sua primeira pesquisa", "alphabetical": "alfabético", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 6d7460d6137f..b6e1b6b60fa5 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "As ferramentas inteligentes de IA estão desativadas para esta organização.", "ai_not_in_plan": "A criação de inquéritos com IA não está disponível no teu plano atual.", "ai_output_too_long": "Este prompt pede mais do que a IA consegue gerar de uma só vez. Simplifica-o ou divide-o em inquéritos mais pequenos.", + "ai_rate_limited": "O fornecedor de IA está ocupado neste momento. Aguarda um instante e tenta novamente.", + "back_to_draft": "Voltar ao rascunho", "card_description": "Descreve o que queres aprender e cria um rascunho de inquérito.", "card_title": "Criar com IA", "characters": "{count}/{max}", "choose_template": "Escolhe um modelo", - "create": "Criar", + "create": "Gerar", "create_with_ai": "Criar com IA", "create_with_ai_description": "Descreve o que precisas e recebe um rascunho do inquérito.", - "creating": "A criar...", "dialog_description": "O Formbricks criará um rascunho que podes editar antes de publicar.", "dialog_title": "Cria o teu inquérito usando IA", + "discard": "Descartar", + "discard_draft_body": "O teu rascunho gerado ainda não foi aberto no editor. Fechar irá descartá-lo.", + "discard_draft_title": "Descartar este rascunho?", + "discard_generation_body": "O questionário ainda está a ser gerado. Fechar irá interrompê-lo e descartar o que foi escrito.", + "discard_generation_title": "Parar a geração?", + "draft_survey": "Rascunho de inquérito", + "edit_prompt": "Editar prompt", "enable_ai_in_settings": "Ativar nas definições", "generated_payload_invalid": "O rascunho do inquérito não pôde ser validado. Tenta adicionar mais detalhes.", - "opening_editor": "A abrir o editor...", + "generation_failed": "Não foi possível concluir o rascunho. Tenta novamente ou adiciona mais alguns detalhes.", + "keep_editing": "Continuar a editar", + "nothing_generated": "Não foram geradas perguntas. Tenta descrever o inquérito com um pouco mais de detalhe.", + "open_in_editor": "Guardar e continuar", + "option_count": "{count, plural, one {# opção} other {# opções}}", "prompt_helper_churn": "Compreende porque é que os clientes ativos podem abandonar e o que os manteria envolvidos", "prompt_helper_churn_label": "Risco de abandono", "prompt_helper_onboarding": "Descobre porque é que os novos utilizadores desistem durante o onboarding e o que os ajudaria a concluir a configuração", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Feedback do website", "prompt_label": "Descreve o inquérito de que precisas", "prompt_placeholder": "Descreve o inquérito de que precisas, por exemplo: Perceber porque é que os novos utilizadores desistem durante o onboarding e o que os ajudaria a concluir a configuração", - "shortcut_hint": "⌘/Ctrl + Enter para criar", + "regenerate": "Gerar novamente", + "request_rejected": "Esse prompt não pôde ser utilizado. Tenta reformulá-lo.", + "shortcut_hint": "⌘/Ctrl + Enter para gerar", "start_from_scratch": "Começar do zero", + "status_planning": "A planear o inquérito…", + "status_starting": "A ler o teu prompt…", + "status_writing_questions": "A escrever a pergunta {count}…", + "status_writing_title": "A dar nome ao teu inquérito…", + "stop": "Parar", + "too_many_requests": "Estás a gerar demasiado rapidamente. Aguarda um momento e tenta novamente.", "try_prompt": "Ver exemplo de prompt", - "upgrade_plan": "Melhorar plano" + "upgrade_plan": "Melhorar plano", + "your_prompt": "O teu prompt" }, "all_set_time_to_create_first_survey": "Está tudo pronto! Hora de criar o seu primeiro inquérito", "alphabetical": "Alfabética", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index bbc8750c12e0..dfee8931c003 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.", "ai_not_in_plan": "Crearea de chestionare cu AI nu este disponibilă în planul tău actual.", "ai_output_too_long": "Acest prompt solicită mai mult decât poate genera AI-ul dintr-o dată. Simplifică-l sau împarte-l în sondaje mai mici.", + "ai_rate_limited": "Furnizorul AI este ocupat în acest moment. Așteaptă puțin și încearcă din nou.", + "back_to_draft": "Înapoi la ciornă", "card_description": "Descrie ce vrei să afli și creează o ciornă de chestionar.", "card_title": "Creează cu AI", "characters": "{count}/{max}", "choose_template": "Alege un șablon", - "create": "Creează", + "create": "Generează", "create_with_ai": "Creează cu AI", "create_with_ai_description": "Descrie ce ai nevoie și primește o schiță de chestionar.", - "creating": "Se creează...", "dialog_description": "Formbricks va crea o schiță pe care o poți edita înainte de publicare.", "dialog_title": "Creează chestionarul folosind AI", + "discard": "Renunță", + "discard_draft_body": "Ciorna generată nu a fost încă deschisă în editor. Închiderea o va șterge.", + "discard_draft_title": "Ștergi această ciornă?", + "discard_generation_body": "Chestionarul este încă în curs de generare. Închiderea va opri procesul și va șterge ceea ce a fost scris.", + "discard_generation_title": "Oprești generarea?", + "draft_survey": "Schiță chestionar", + "edit_prompt": "Editează prompt-ul", "enable_ai_in_settings": "Activează în setări", "generated_payload_invalid": "Schița chestionarului nu a putut fi validată. Încearcă să adaugi mai multe detalii.", - "opening_editor": "Se deschide editorul...", + "generation_failed": "Schița nu a putut fi finalizată. Încearcă din nou sau adaugă mai multe detalii.", + "keep_editing": "Continuă editarea", + "nothing_generated": "Nu s-a generat nicio întrebare. Încearcă să descrii chestionarul mai detaliat.", + "open_in_editor": "Salvează și continuă", + "option_count": "{count, plural, one {# opțiune} few {# opțiuni} other {# de opțiuni}}", "prompt_helper_churn": "Înțelege de ce clienții activi ar putea pleca și ce i-ar menține implicați", "prompt_helper_churn_label": "Risc de abandon", "prompt_helper_onboarding": "Află de ce utilizatorii noi renunță în timpul procesului de onboarding și ce i-ar ajuta să finalizeze configurarea", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Feedback site web", "prompt_label": "Descrie chestionarul de care ai nevoie", "prompt_placeholder": "Descrie chestionarul de care ai nevoie, de ex. Înțelege de ce utilizatorii noi se opresc în timpul onboarding-ului și ce i-ar ajuta să finalizeze configurarea", - "shortcut_hint": "⌘/Ctrl + Enter pentru a crea", + "regenerate": "Regenerează", + "request_rejected": "Acel prompt nu a putut fi utilizat. Încearcă să-l reformulezi.", + "shortcut_hint": "⌘/Ctrl + Enter pentru a genera", "start_from_scratch": "Începe de la zero", + "status_planning": "Se planifică chestionarul…", + "status_starting": "Se citește prompt-ul tău…", + "status_writing_questions": "Se scrie întrebarea {count}…", + "status_writing_title": "Se denumește chestionarul…", + "stop": "Oprește", + "too_many_requests": "Generezi prea repede. Așteaptă un moment și încearcă din nou.", "try_prompt": "Vezi exemplu de solicitare", - "upgrade_plan": "Actualizează planul" + "upgrade_plan": "Actualizează planul", + "your_prompt": "Prompt-ul tău" }, "all_set_time_to_create_first_survey": "Ești gata! Este timpul să creezi primul tău chestionar", "alphabetical": "Alfabetic", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 4ec7e6331c72..cc4c93d81f24 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "Умные инструменты ИИ отключены для этой организации.", "ai_not_in_plan": "Создание опросов с помощью ИИ недоступно в твоём текущем тарифе.", "ai_output_too_long": "Этот запрос требует больше, чем ИИ может создать за один раз. Упрости его или раздели на несколько опросов.", + "ai_rate_limited": "Провайдер ИИ сейчас перегружен. Подожди немного и попробуй снова.", + "back_to_draft": "Вернуться к черновику", "card_description": "Опиши, что ты хочешь узнать, и создай черновик опроса.", "card_title": "Создать с помощью ИИ", "characters": "{count}/{max}", "choose_template": "Выбери шаблон", - "create": "Создать", + "create": "Сгенерировать", "create_with_ai": "Создать с помощью ИИ", "create_with_ai_description": "Опиши, что тебе нужно, и получи черновик опроса.", - "creating": "Создаём...", "dialog_description": "Formbricks создаст черновик, который вы сможете отредактировать перед публикацией.", "dialog_title": "Создайте свой опрос с помощью ИИ", + "discard": "Отменить", + "discard_draft_body": "Созданный черновик ещё не был открыт в редакторе. При закрытии он будет удалён.", + "discard_draft_title": "Удалить этот черновик?", + "discard_generation_body": "Опрос всё ещё создаётся. При закрытии генерация остановится, а написанное будет удалено.", + "discard_generation_title": "Остановить генерацию?", + "draft_survey": "Черновик опроса", + "edit_prompt": "Изменить запрос", "enable_ai_in_settings": "Включи в настройках", "generated_payload_invalid": "Черновик опроса не удалось проверить. Попробуй добавить больше деталей.", - "opening_editor": "Открываем редактор...", + "generation_failed": "Не удалось завершить черновик. Попробуй ещё раз или добавь больше деталей.", + "keep_editing": "Продолжить редактирование", + "nothing_generated": "Вопросы не были созданы. Попробуй описать опрос более подробно.", + "open_in_editor": "Сохранить и продолжить", + "option_count": "{count, plural, one {# вариант} few {# варианта} many {# вариантов} other {# вариантов}}", "prompt_helper_churn": "Узнай, почему активные клиенты могут уйти и что удержит их вовлечёнными", "prompt_helper_churn_label": "Риск оттока", "prompt_helper_onboarding": "Узнайте, почему новые пользователи прекращают онбординг и что помогло бы им завершить настройку", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Отзывы о сайте", "prompt_label": "Опиши нужный опрос", "prompt_placeholder": "Опишите необходимый опрос, например: Понять, почему новые пользователи прерывают процесс онбординга и что поможет им завершить настройку", - "shortcut_hint": "⌘/Ctrl + Enter для создания", + "regenerate": "Создать заново", + "request_rejected": "Этот запрос не может быть использован. Попробуй переформулировать его.", + "shortcut_hint": "⌘/Ctrl + Enter для генерации", "start_from_scratch": "Начать с нуля", + "status_planning": "Планирую опрос…", + "status_starting": "Читаю твой запрос…", + "status_writing_questions": "Пишу вопрос {count}…", + "status_writing_title": "Придумываю название для опроса…", + "stop": "Остановить", + "too_many_requests": "Ты генерируешь слишком быстро. Подожди немного и попробуй снова.", "try_prompt": "Посмотреть пример запроса", - "upgrade_plan": "Обновить план" + "upgrade_plan": "Обновить план", + "your_prompt": "Твой запрос" }, "all_set_time_to_create_first_survey": "Всё готово! Пора создать первый опрос", "alphabetical": "По алфавиту", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index a49904977821..5d464927ea25 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "AI-smarta verktyg är inaktiverade för den här organisationen.", "ai_not_in_plan": "AI-enkätskapande är inte tillgängligt i din nuvarande plan.", "ai_output_too_long": "Den här prompten begär mer än vad AI:n kan generera på en gång. Förenkla den eller dela upp den i mindre enkäter.", + "ai_rate_limited": "AI-tjänsten är upptagen just nu. Vänta en stund och försök igen.", + "back_to_draft": "Tillbaka till utkast", "card_description": "Beskriv vad du vill lära dig och skapa ett utkast till enkät.", "card_title": "Skapa med AI", "characters": "{count}/{max}", "choose_template": "Välj en mall", - "create": "Skapa", + "create": "Generera", "create_with_ai": "Skapa med AI", "create_with_ai_description": "Beskriv vad du behöver så får du ett utkast till enkät.", - "creating": "Skapar...", "dialog_description": "Formbricks skapar ett utkast som du kan redigera innan publicering.", "dialog_title": "Skapa din enkät med hjälp av AI", + "discard": "Förkasta", + "discard_draft_body": "Ditt genererade utkast har inte öppnats i redigeraren ännu. Att stänga kommer att förkasta det.", + "discard_draft_title": "Förkasta det här utkastet?", + "discard_generation_body": "Undersökningen håller fortfarande på att genereras. Att stänga kommer att stoppa den och förkasta det som har skrivits.", + "discard_generation_title": "Sluta generera?", + "draft_survey": "Utkast till enkät", + "edit_prompt": "Redigera prompt", "enable_ai_in_settings": "Aktivera i inställningar", "generated_payload_invalid": "Enkätutkastet kunde inte valideras. Försök lägga till mer detaljer.", - "opening_editor": "Öppnar redigeraren...", + "generation_failed": "Utkastet kunde inte färdigställas. Försök igen eller lägg till lite mer detaljer.", + "keep_editing": "Fortsätt redigera", + "nothing_generated": "Inga frågor genererades. Försök beskriva enkäten lite mer detaljerat.", + "open_in_editor": "Spara och fortsätt", + "option_count": "{count, plural, one {# alternativ} other {# alternativ}", "prompt_helper_churn": "Förstå varför aktiva kunder kan försvinna och vad som skulle hålla dem engagerade", "prompt_helper_churn_label": "Churnrisk", "prompt_helper_onboarding": "Ta reda på varför nya användare avbryter under onboarding och vad som skulle hjälpa dem att slutföra installationen", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Webbplatsfeedback", "prompt_label": "Beskriv enkäten du behöver", "prompt_placeholder": "Beskriv den enkät du behöver, t.ex. Förstå varför nya användare avbryter under onboarding och vad som skulle hjälpa dem att slutföra konfigurationen", - "shortcut_hint": "⌘/Ctrl + Enter för att skapa", + "regenerate": "Generera om", + "request_rejected": "Den prompten kunde inte användas. Försök omformulera den.", + "shortcut_hint": "⌘/Ctrl + Enter för att generera", "start_from_scratch": "Börja från början", + "status_planning": "Planerar enkäten…", + "status_starting": "Läser din prompt…", + "status_writing_questions": "Skriver fråga {count}…", + "status_writing_title": "Namnger din enkät…", + "stop": "Stoppa", + "too_many_requests": "Du genererar för snabbt. Vänta ett ögonblick och försök igen.", "try_prompt": "Se exempelprompt", - "upgrade_plan": "Uppgradera abonnemang" + "upgrade_plan": "Uppgradera abonnemang", + "your_prompt": "Din prompt" }, "all_set_time_to_create_first_survey": "Allt klart! Dags att skapa din första enkät", "alphabetical": "Alfabetisk", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index de8cebcfafa3..084f86e3dbba 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -2979,6 +2979,8 @@ "ai_not_enabled": "Bu organizasyon için AI akıllı araçlar devre dışı.", "ai_not_in_plan": "AI ile anket oluşturma mevcut planında mevcut değil.", "ai_output_too_long": "Bu istem, yapay zekanın tek seferde üretebileceğinden daha fazlasını istiyor. Daha basit hale getir veya daha küçük anketlere böl.", + "ai_rate_limited": "AI sağlayıcısı şu anda meşgul. Biraz bekleyip tekrar dene.", + "back_to_draft": "Taslağa dön", "card_description": "Ne öğrenmek istediğini anlat ve bir taslak anket oluştur.", "card_title": "AI ile Oluştur", "characters": "{count}/{max}", @@ -2986,12 +2988,22 @@ "create": "Oluştur", "create_with_ai": "AI ile Oluştur", "create_with_ai_description": "İhtiyacını tarif et ve anket taslağını al.", - "creating": "Oluşturuluyor...", "dialog_description": "Formbricks yayınlamadan önce düzenleyebileceğin bir taslak oluşturacak.", "dialog_title": "Yapay zeka kullanarak anketini oluştur", + "discard": "İptal et", + "discard_draft_body": "Oluşturulan taslağın henüz düzenleyicide açılmadı. Kapatırsan taslak silinecek.", + "discard_draft_title": "Bu taslak silinsin mi?", + "discard_generation_body": "Anket hâlâ oluşturuluyor. Kapatırsan işlem duracak ve yazılanlar silinecek.", + "discard_generation_title": "Oluşturma durdurulsun mu?", + "draft_survey": "Taslak anket", + "edit_prompt": "İstemi düzenle", "enable_ai_in_settings": "Ayarlardan etkinleştir", "generated_payload_invalid": "Anket taslağı doğrulanamadı. Daha fazla detay eklemeyi dene.", - "opening_editor": "Düzenleyici açılıyor...", + "generation_failed": "Taslak tamamlanamadı. Tekrar dene veya biraz daha detay ekle.", + "keep_editing": "Düzenlemeye devam et", + "nothing_generated": "Hiçbir soru gelmedi. Anketi biraz daha detaylı açıklamayı dene.", + "open_in_editor": "Kaydet ve devam et", + "option_count": "{count, plural, one {# seçenek} other {# seçenek}}", "prompt_helper_churn": "Aktif müşterilerin neden kaybedilebileceğini ve onları bağlı tutacak şeylerin neler olduğunu anla", "prompt_helper_churn_label": "Kayıp riski", "prompt_helper_onboarding": "Yeni kullanıcıların onboarding sırasında neden ayrıldığını ve kurulumu tamamlamalarına neyin yardımcı olacağını öğrenin", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "Web sitesi geri bildirimi", "prompt_label": "İhtiyacın olan anketi açıkla", "prompt_placeholder": "İhtiyacın olan anketi tanımla, örneğin: Yeni kullanıcıların neden onboarding sırasında durduğunu ve kurulumu tamamlamalarına neyin yardımcı olacağını anla", + "regenerate": "Yeniden oluştur", + "request_rejected": "Bu istem kullanılamadı. Yeniden ifade etmeyi dene.", "shortcut_hint": "Oluşturmak için ⌘/Ctrl + Enter", "start_from_scratch": "Sıfırdan başla", + "status_planning": "Anket planlanıyor…", + "status_starting": "İstemin okunuyor…", + "status_writing_questions": "Soru {count} yazılıyor…", + "status_writing_title": "Anketin adı belirleniyor…", + "stop": "Durdur", + "too_many_requests": "Çok hızlı oluşturuyorsun. Bir an bekle ve tekrar dene.", "try_prompt": "Örnek komutu gör", - "upgrade_plan": "Planı yükselt" + "upgrade_plan": "Planı yükselt", + "your_prompt": "İstemin" }, "all_set_time_to_create_first_survey": "Her şey hazır! İlk anketini oluşturma zamanı", "alphabetical": "Alfabetik", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 6465d7bde1c0..d2578fd75eeb 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "此组织已禁用 AI 智能工具。", "ai_not_in_plan": "你当前的套餐不支持 AI 问卷创建功能。", "ai_output_too_long": "此提示要求生成的内容超出了 AI 一次性生成的能力。请简化提示或将其拆分为多个较小的调查问卷。", + "ai_rate_limited": "AI 服务商目前繁忙。请稍等片刻后重试。", + "back_to_draft": "返回草稿", "card_description": "描述你想了解的内容,创建问卷草稿。", "card_title": "使用 AI 创建", "characters": "{count}/{max}", "choose_template": "选择模板", - "create": "创建", + "create": "生成", "create_with_ai": "使用 AI 创建", "create_with_ai_description": "描述你的需求,即可获得调查问卷草稿。", - "creating": "正在创建...", "dialog_description": "Formbricks 将创建一份草稿,你可以在发布前进行编辑。", "dialog_title": "使用 AI 创建你的调查问卷", + "discard": "丢弃", + "discard_draft_body": "您生成的草稿尚未在编辑器中打开。关闭将丢弃该草稿。", + "discard_draft_title": "丢弃此草稿?", + "discard_generation_body": "调查问卷仍在生成中。关闭将停止生成并丢弃已写入的内容。", + "discard_generation_title": "停止生成?", + "draft_survey": "调查草稿", + "edit_prompt": "编辑提示词", "enable_ai_in_settings": "在设置中启用", "generated_payload_invalid": "问卷草稿无法验证。请尝试添加更多详细信息。", - "opening_editor": "正在打开编辑器...", + "generation_failed": "草稿未能完成。请重试,或添加更多详细信息。", + "keep_editing": "继续编辑", + "nothing_generated": "未生成任何问题。请尝试更详细地描述调查。", + "open_in_editor": "保存并继续", + "option_count": "{count, plural, other {# 个选项}}", "prompt_helper_churn": "了解活跃客户可能流失的原因以及什么能让他们保持参与", "prompt_helper_churn_label": "流失风险", "prompt_helper_onboarding": "了解新用户为什么在引导过程中流失,以及哪些帮助能让他们完成设置", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "网站反馈", "prompt_label": "描述你需要的问卷", "prompt_placeholder": "描述你需要的调查,例如:了解新用户在引导过程中为何停止以及哪些因素能帮助他们完成设置", - "shortcut_hint": "⌘/Ctrl + Enter 创建", + "regenerate": "重新生成", + "request_rejected": "无法使用该提示词。请尝试重新表述。", + "shortcut_hint": "⌘/Ctrl + Enter 生成", "start_from_scratch": "从头开始", + "status_planning": "正在规划调查…", + "status_starting": "正在读取你的提示词…", + "status_writing_questions": "正在撰写第 {count} 个问题…", + "status_writing_title": "正在为你的调查命名…", + "stop": "停止", + "too_many_requests": "生成速度过快。请稍等片刻后重试。", "try_prompt": "查看示例提示", - "upgrade_plan": "升级套餐" + "upgrade_plan": "升级套餐", + "your_prompt": "你的提示词" }, "all_set_time_to_create_first_survey": "一切准备就绪!是时候创建您的第一个调查了", "alphabetical": "字母顺序", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 4ec8d1db7d7d..2b6f4095d7c1 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -2979,19 +2979,31 @@ "ai_not_enabled": "此組織已停用 AI 智慧工具。", "ai_not_in_plan": "你目前的方案不包含 AI 問卷建立功能。", "ai_output_too_long": "這個提示要求的內容超過 AI 一次能生成的範圍。請簡化提示或將其拆分為多個較小的問卷。", + "ai_rate_limited": "AI 服務商目前忙碌中,請稍候片刻再試一次。", + "back_to_draft": "返回草稿", "card_description": "描述你想了解的內容,即可建立問卷草稿。", "card_title": "使用 AI 建立", "characters": "{count}/{max}", "choose_template": "選擇範本", - "create": "建立", + "create": "生成", "create_with_ai": "使用 AI 建立", "create_with_ai_description": "描述你的需求,即可獲得問卷草稿。", - "creating": "建立中...", "dialog_description": "Formbricks 會建立一份草稿,您可以在發布前進行編輯。", "dialog_title": "使用 AI 建立問卷", + "discard": "捨棄", + "discard_draft_body": "你產生的草稿尚未在編輯器中開啟。關閉將會捨棄它。", + "discard_draft_title": "要捨棄此草稿嗎?", + "discard_generation_body": "問卷仍在產生中。關閉將會停止產生並捨棄已撰寫的內容。", + "discard_generation_title": "要停止產生嗎?", + "draft_survey": "草稿問卷", + "edit_prompt": "編輯提示", "enable_ai_in_settings": "在設定中啟用", "generated_payload_invalid": "問卷草稿無法驗證。請嘗試新增更多細節。", - "opening_editor": "正在開啟編輯器...", + "generation_failed": "無法完成草稿。請再試一次,或新增更多細節。", + "keep_editing": "繼續編輯", + "nothing_generated": "沒有產生任何問題。請試著更詳細地描述問卷內容。", + "open_in_editor": "儲存並繼續", + "option_count": "{count, plural, other {# 個選項}}", "prompt_helper_churn": "了解活躍客戶可能流失的原因,以及如何讓他們保持參與度", "prompt_helper_churn_label": "流失風險", "prompt_helper_onboarding": "了解新用戶為何在引導過程中停止,以及什麼能幫助他們完成設定", @@ -3002,10 +3014,19 @@ "prompt_helper_website_label": "網站意見回饋", "prompt_label": "描述你需要的問卷", "prompt_placeholder": "描述你需要的問卷調查,例如:了解新使用者為何在引導過程中停止操作,以及什麼能幫助他們完成設定", - "shortcut_hint": "⌘/Ctrl + Enter 建立", + "regenerate": "重新產生", + "request_rejected": "無法使用該提示。請試著換個方式表達。", + "shortcut_hint": "⌘/Ctrl + Enter 生成", "start_from_scratch": "從零開始", + "status_planning": "正在規劃問卷⋯", + "status_starting": "正在讀取你的提示⋯", + "status_writing_questions": "正在撰寫第 {count} 個問題⋯", + "status_writing_title": "正在為你的問卷命名⋯", + "stop": "停止", + "too_many_requests": "你產生的速度太快了。請稍等片刻再試一次。", "try_prompt": "查看範例提示", - "upgrade_plan": "升級方案" + "upgrade_plan": "升級方案", + "your_prompt": "你的提示" }, "all_set_time_to_create_first_survey": "您已準備就緒!是時候建立您的第一個問卷", "alphabetical": "依字母順序", diff --git a/apps/web/modules/survey/components/template-list/components/ai-draft-preview.tsx b/apps/web/modules/survey/components/template-list/components/ai-draft-preview.tsx new file mode 100644 index 000000000000..e42989c9a5c8 --- /dev/null +++ b/apps/web/modules/survey/components/template-list/components/ai-draft-preview.tsx @@ -0,0 +1,194 @@ +"use client"; + +import type React from "react"; +import { type ReactNode, memo, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import type { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; +import { cn } from "@/lib/cn"; +import { + type TAiDraftQuestion, + type TAiDraftState, + groupAiDraftByBlock, +} from "@/modules/survey/components/template-list/lib/ai-draft-reducer"; +import { getElementIconMap, getElementNameMap } from "@/modules/survey/lib/elements"; +import { AiActivityBar } from "@/modules/ui/components/ai"; +import { Skeleton } from "@/modules/ui/components/skeleton"; + +/** How close to the bottom still counts as "following along", in px. */ +const PIN_THRESHOLD_PX = 32; +const PENDING_ROW_COUNT = 3; +const CHOICE_ELEMENT_TYPES = new Set(["multipleChoiceSingle", "multipleChoiceMulti", "ranking", "matrix"]); + +type AiDraftRowProps = { + question: TAiDraftQuestion; + icon?: ReactNode; + typeName?: string; + t: (key: string, options?: Record) => string; +}; + +/** + * Memoised, and the reducer guarantees an unchanged question keeps its object identity — together + * that is what stops every row re-rendering on each snapshot. + */ +const AiDraftRow = memo(({ question, icon, typeName, t }: Readonly) => { + const showsOptionCount = question.type ? CHOICE_ELEMENT_TYPES.has(question.type) : false; + const optionCount = + showsOptionCount && question.choiceCount + ? t("workspace.surveys.ai_create.option_count", { count: question.choiceCount }) + : undefined; + + return ( +
  • + {icon} +
    + {question.headline ? ( + // Semibold, primary ink: the question is the content, so it has to outweigh everything + // else in the row. truncate, never wrap — a headline growing character by character + // extends rightwards and clips, so the row height is fixed from the moment it mounts. +

    {question.headline}

    + ) : ( + // Same height as the text it becomes, and no fade on the swap — a transition on every + // keystroke-sized update is exactly what reads as flicker. + + )} + {/* + Muted plain text rather than a pill: a chip carries a border and a fill, which made the + metadata louder than the question it describes. The weight gap against the semibold + headline is what stops this reading as an answer to it. Matches the row the survey editor + already uses for the same job, down to the token. + */} +

    + {typeName ?? "\u00a0"} + {optionCount ? ` · ${optionCount}` : ""} +

    +
    +
  • + ); +}); + +AiDraftRow.displayName = "AiDraftRow"; + +type AiDraftPreviewProps = { + draft: TAiDraftState; + isGenerating: boolean; + className?: string; + /** The scroll container, exposed so a finished generation can land focus where scrolling works. */ + scrollContainerRef?: React.RefObject; +}; + +export const AiDraftPreview = ({ + draft, + isGenerating, + className, + scrollContainerRef, +}: Readonly) => { + const { t } = useTranslation(); + // The same glyphs and labels the editor uses two seconds later, so the draft reads as the product + // rather than as a bespoke preview. + const iconMap = useMemo(() => getElementIconMap(t), [t]); + const nameMap = useMemo(() => getElementNameMap(t), [t]); + + const blocks = useMemo(() => groupAiDraftByBlock(draft.questions), [draft.questions]); + const showsBlockNames = blocks.length > 1; + + const scrollRef = useRef(null); + const isPinnedRef = useRef(true); + + useEffect(() => { + const element = scrollRef.current; + if (!element || !isPinnedRef.current) return; + + // The CSS reduced-motion kill-switch cannot reach a JS scroll option, so check it here. + const prefersReducedMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + element.scrollTo({ top: element.scrollHeight, behavior: prefersReducedMotion ? "auto" : "smooth" }); + }, [draft.questions.length]); + + const handleScroll = () => { + const element = scrollRef.current; + if (!element) return; + + isPinnedRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < PIN_THRESHOLD_PX; + }; + + return ( +
    + {isGenerating ? : null} + +
    + {draft.name ? ( +

    {draft.name}

    + ) : ( + + )} +
    + + {/* + The focus target is the scroll container itself, not the card around it: a tabIndex on a + non-scrolling ancestor gives a keyboard user a tab stop that cannot scroll anything (WCAG + 2.1.1 — only Firefox makes overflow containers focusable on its own). One stop, on the + element that moves, which is also where the finished generation lands focus. + */} +
    { + scrollRef.current = node; + if (scrollContainerRef) scrollContainerRef.current = node; + }} + onScroll={handleScroll} + // A scroll container that cannot take focus cannot be scrolled from the keyboard, which is + // the WCAG 2.1.1 failure this exists to fix — so the tabIndex stays on a non-interactive + // element on purpose. `
    ` with an accessible name is a region natively, which is why + // there is no `role` here. + tabIndex={0} // NOSONAR + aria-label={t("workspace.surveys.ai_create.draft_survey")} + aria-busy={isGenerating} + className="focus-visible:ring-ring min-h-0 flex-1 overflow-y-auto focus-visible:ring-1 focus-visible:outline-hidden"> + {blocks.map((block) => ( +
    + {/* + The model is asked to name every block, so show that structure rather than flattening + it into one list — but only when there is more than one, since a lone header over the + whole draft is chrome that says nothing. + + Sticky so the section you are reading stays named while you scroll through it. The + band is opaque, so rows pass cleanly behind it rather than showing through. + */} + {showsBlockNames && ( +

    + {block.name ?? } +

    + )} +
      + {block.questions.map((question) => ( + + ))} +
    +
    + ))} + + {isGenerating ? ( +
      + {Array.from({ length: PENDING_ROW_COUNT }, (_, index) => ( +
    • + +
      + + +
      +
    • + ))} +
    + ) : null} +
    +
    + ); +}; diff --git a/apps/web/modules/survey/components/template-list/components/create-with-ai-dialog.tsx b/apps/web/modules/survey/components/template-list/components/create-with-ai-dialog.tsx index 9f0b171279d0..3334d28fef96 100644 --- a/apps/web/modules/survey/components/template-list/components/create-with-ai-dialog.tsx +++ b/apps/web/modules/survey/components/template-list/components/create-with-ai-dialog.tsx @@ -1,13 +1,13 @@ "use client"; -import { SparklesIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { type ReactNode, useRef, useState, useTransition } from "react"; +import { type ReactNode, useCallback, useRef, useState, useTransition } from "react"; import { useTranslation } from "react-i18next"; import type { TUserLocale } from "@formbricks/types/user"; import type { TAIUnavailableReason } from "@/lib/ai/service"; import { CreateWithAIForm } from "@/modules/survey/components/template-list/components/create-with-ai-form"; -import { Button } from "@/modules/ui/components/button"; +import { AiIcon } from "@/modules/ui/components/ai"; +import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { Dialog, DialogBody, @@ -42,20 +42,36 @@ export const CreateWithAIDialog = ({ const router = useRouter(); const [internalOpen, setInternalOpen] = useState(false); const [isNavigating, startEditorNavigationTransition] = useTransition(); + const [hasUnsavedWork, setHasUnsavedWork] = useState(false); + const [isConfirmingDiscard, setIsConfirmingDiscard] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); const promptInputRef = useRef(null); const isControlled = open !== undefined; const isOpen = isControlled ? open : internalOpen; - const isBusy = isNavigating; + + const commitOpenChange = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setInternalOpen(nextOpen); + } + + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange] + ); const setDialogOpen = (nextOpen: boolean) => { - if (isBusy && !nextOpen) return; + if (isNavigating && !nextOpen) return; - if (!isControlled) { - setInternalOpen(nextOpen); + // Reloading the page already warns; closing the dialog throws away the same work, so it asks + // too rather than silently discarding a generation the user waited for. + if (!nextOpen && hasUnsavedWork) { + setIsConfirmingDiscard(true); + return; } - onOpenChange?.(nextOpen); + commitOpenChange(nextOpen); }; const handleSuccess = (surveyId: string) => { @@ -65,7 +81,7 @@ export const CreateWithAIDialog = ({ }; const handleOpenAutoFocus = (event: Event) => { - if (!isAIAvailable || isBusy) return; + if (!isAIAvailable) return; event.preventDefault(); globalThis.requestAnimationFrame(() => { @@ -73,21 +89,40 @@ export const CreateWithAIDialog = ({ }); }; + // Read as plain calls rather than inside the ternary below: the translation scanner only sees + // `t("literal")`, so a key passed through a conditional expression reads as unused and is pruned. + const discardGenerationTitle = t("workspace.surveys.ai_create.discard_generation_title"); + const discardDraftTitle = t("workspace.surveys.ai_create.discard_draft_title"); + const discardGenerationBody = t("workspace.surveys.ai_create.discard_generation_body"); + const discardDraftBody = t("workspace.surveys.ai_create.discard_draft_body"); + return ( {trigger ? {trigger} : null} + // A stray click outside must not kill a twenty-second generation, but Escape should still + // work — disableCloseOnOutsideClick swallows it unless closeOnEscape opts back in. + disableCloseOnOutsideClick + closeOnEscape> - - + {/* Fixed height, not min-height: the modal reaches its final geometry on first paint and + never moves again — not on submit, not as questions append, not on completion. + flex-none is load-bearing: DialogBody is flex-1 by default, which makes it size to its + content and the height a no-op. */} + ( - - - - - )} + showCancel + isHostNavigating={isNavigating} + onUnsavedWorkChange={(unsaved) => { + setHasUnsavedWork(unsaved); + }} + onGeneratingChange={setIsGenerating} + onCancel={() => setDialogOpen(false)} + renderFooter={(footer) => {footer}} /> + + { + setIsConfirmingDiscard(false); + setHasUnsavedWork(false); + commitOpenChange(false); + }} + /> ); }; diff --git a/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx b/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx index 38ad1eae64bc..a8787cf3f33b 100644 --- a/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx +++ b/apps/web/modules/survey/components/template-list/components/create-with-ai-form.tsx @@ -1,27 +1,24 @@ "use client"; -import { SparklesIcon } from "lucide-react"; +import { ArrowLeftIcon, PencilIcon } from "lucide-react"; import Link from "next/link"; -import { type KeyboardEvent, type ReactNode, useMemo } from "react"; +import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { TUserLocale } from "@formbricks/types/user"; import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context"; import { getAIUnavailableAction } from "@/lib/ai/availability"; import type { TAIUnavailableReason } from "@/lib/ai/service"; +import { AiDraftPreview } from "@/modules/survey/components/template-list/components/ai-draft-preview"; import { useCreateSurveyWithAI } from "@/modules/survey/components/template-list/hooks/use-create-survey-with-ai"; import { AI_SURVEY_PROMPT_MAX_LENGTH, getHelperPrompts, getUnavailableMessageKey, } from "@/modules/survey/components/template-list/lib/ai-create-utils"; +import { AiIcon, AiStatusLine } from "@/modules/ui/components/ai"; import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; - -export type TCreateWithAIFormFooterProps = { - isBusy: boolean; - canCreate: boolean; - submitLabel: string; -}; +import { TooltipRenderer } from "@/modules/ui/components/tooltip"; type CreateWithAIFormProps = { workspaceId: string; @@ -31,8 +28,19 @@ type CreateWithAIFormProps = { onSuccess: (surveyId: string) => void; onCancel?: () => void; showCancel?: boolean; - renderFooter?: (props: TCreateWithAIFormFooterProps) => ReactNode; + /** + * The host supplies the footer *shell* only — `` in a dialog, a plain row on a + * page. The buttons themselves are built here, because with three states and state-dependent + * actions every host would otherwise duplicate the same switch. + */ + renderFooter?: (footer: ReactNode) => ReactNode; promptInputRef?: React.Ref; + /** True while the host is navigating away, so the review primary can stay in its loading state. */ + isHostNavigating?: boolean; + /** Reports whether closing now would discard an in-flight generation or an unopened draft. */ + onUnsavedWorkChange?: (hasUnsavedWork: boolean) => void; + /** Reports whether a generation is in flight, so the host can word its confirmation. */ + onGeneratingChange?: (isGenerating: boolean) => void; }; export const CreateWithAIForm = ({ @@ -45,17 +53,64 @@ export const CreateWithAIForm = ({ showCancel = true, renderFooter, promptInputRef, + isHostNavigating = false, + onUnsavedWorkChange, + onGeneratingChange, }: Readonly) => { const { t } = useTranslation(); const { workspace } = useWorkspace(); - const { prompt, setPrompt, isBusy, canCreate, errorMessage, handleGenerate, clearError, submitLabel } = - useCreateSurveyWithAI({ - workspaceId, - language, - isAIAvailable, - onSuccess, - }); + const { + prompt, + setPrompt, + submittedPrompt, + status, + draft, + canCreate, + errorMessage, + generatingMessages, + statusIndex, + isCreatingSurvey, + handleGenerate, + handleStop, + handleRegenerate, + handleEditPrompt, + handleBackToDraft, + handleOpenInEditor, + clearError, + hasKeptDraft, + hasUnsavedWork, + } = useCreateSurveyWithAI({ workspaceId, language, isAIAvailable, onSuccess }); + + const stopButtonRef = useRef(null); + const draftRef = useRef(null); + + const isGenerating = status === "generating"; + const isReviewing = status === "review" || status === "creating"; + + // The textarea unmounts when generation starts, so without this focus falls to and a + // keyboard user is stranded. Stop is the only action available, so it is where focus belongs. + useEffect(() => { + if (isGenerating) { + stopButtonRef.current?.focus(); + } + }, [isGenerating]); + + useEffect(() => { + onUnsavedWorkChange?.(hasUnsavedWork); + }, [hasUnsavedWork, onUnsavedWorkChange]); + + useEffect(() => { + onGeneratingChange?.(isGenerating); + }, [isGenerating, onGeneratingChange]); + + // On completion focus the draft's scroll container rather than "Open in editor": it is the element + // that scrolls, and a user pressing Space to read further would otherwise navigate by accident. + useEffect(() => { + if (status === "review") { + draftRef.current?.focus(); + } + }, [status]); const unavailableAction = workspace?.organizationId ? getAIUnavailableAction(aiUnavailableReason, workspace.organizationId) @@ -76,24 +131,108 @@ export const CreateWithAIForm = ({ } }; - const defaultFooter = ( -
    - {showCancel && onCancel && ( - - )} - -
    + ); + } + + if (isReviewing) { + return ( + <> + {showCancel && onCancel && ( + // Closing from review discards a draft nobody has opened, so this routes through the + // same confirmation the dialog's X and Escape do. + + )} + + {/* `loading` is right here and wrong while generating: this is an ordinary save, and a + spinner reads as "saving". Thinking gets the twinkling mark instead. */} + + + ); + } + + return ( + <> + {showCancel && onCancel && ( + + )} + {hasKeptDraft && ( + + )} + + + ); + }; + + const footer = buildFooter(); + // mt-auto pins the footer to the bottom of the fixed frame, so it does not drift up in the + // shorter idle state. + const footerContent = renderFooter ? ( +
    {renderFooter(footer)}
    + ) : ( +
    {footer}
    ); - const footerContent = renderFooter ? renderFooter({ isBusy, canCreate, submitLabel }) : defaultFooter; + const editPromptLabel = t("workspace.surveys.ai_create.edit_prompt"); + + /** + * The prompt, settled. It is the same content the textarea held, so it borrows that component's + * shape — same radius and text size — with a lighter border and a filled ground to say it is no + * longer the thing you are editing. The pencil lives inside that frame: pinned to the dialog edge + * instead, it read as an unrelated control floating in whitespace. + */ + const promptChip = ( +
    +

    + {t("workspace.surveys.ai_create.your_prompt")}: + {/* + The prompt this draft came from, not the one being typed. Edit prompt keeps the draft, so + the live text would label an old draft with words that had no part in producing it. + */} + {submittedPrompt} +

    + + + +
    + ); return ( - + {!isAIAvailable && ( {t("workspace.surveys.ai_create.ai_not_available")} @@ -113,54 +252,72 @@ export const CreateWithAIForm = ({ )} -
    -