From 323852c2f53eb8a97aa1af139fccfa63528e09d2 Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Wed, 22 Oct 2025 19:58:52 -0700 Subject: [PATCH 01/13] feat: add pylon chat widget --- .env.example | 3 ++ src/components/GlobalScripts.tsx | 65 ++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index b8cfc8f..a5f822e 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,6 @@ NEXT_PUBLIC_RB2B_KEY= # Search mode: 'fumadocs' (default, uses Fumadocs built-in search) or 'rag' (uses RAG endpoint at mcp.superwall.com) SEARCH_MODE=fumadocs + +NEXT_PUBLIC_PYLON_APP_ID= +PYLON_IDENTITY_SECRET= \ No newline at end of file diff --git a/src/components/GlobalScripts.tsx b/src/components/GlobalScripts.tsx index f4bf79a..76157d2 100644 --- a/src/components/GlobalScripts.tsx +++ b/src/components/GlobalScripts.tsx @@ -15,9 +15,60 @@ export function GlobalScripts({ location }: GlobalScriptsProps) { const unifyScriptSrc = process.env.NEXT_PUBLIC_UNIFY_SCRIPT_SRC; const unifyApiKey = process.env.NEXT_PUBLIC_UNIFY_API_KEY; const rb2bKey = process.env.NEXT_PUBLIC_RB2B_KEY; + const pylonAppId = process.env.NEXT_PUBLIC_PYLON_APP_ID; + + // Only show Pylon in development + const isDev = process.env.NEXTJS_ENV === 'development' || process.env.NODE_ENV === 'development'; + + // Separate script for Pylon in local development only + const pylonLocalDevScript = isDev ? ` + (function() { + var isLocalhost = typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); + + if (!isLocalhost) return; + + var pylonAppId = ${toJsStringLiteral(pylonAppId)}; + if (!pylonAppId) return; + + // Load Pylon widget script + (function(){ + var e=window; + var t=document; + var n=function(){n.e(arguments)}; + n.q=[]; + n.e=function(e){n.q.push(e)}; + e.Pylon=n; + var r=function(){ + var e=t.createElement("script"); + e.setAttribute("type","text/javascript"); + e.setAttribute("async","true"); + e.setAttribute("src","https://widget.usepylon.com/widget/" + pylonAppId); + var n=t.getElementsByTagName("script")[0]; + n.parentNode.insertBefore(e,n); + }; + if(t.readyState==="complete"){r()} + else if(e.addEventListener){e.addEventListener("load",r,false)} + })(); + + // Configure Pylon with test user data for local dev + window.pylon = { + chat_settings: { + app_id: pylonAppId, + email: "dev@superwall.com", + name: "Local Dev User" + } + }; + })(); + ` : ''; const scriptContent = ` (async function () { + // Skip on localhost (handled by separate local dev script) + var isLocalhost = typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); + if (isLocalhost) return; + try { var response = await fetch('/api/auth/session', { credentials: 'include' }); var isLoggedIn = true; @@ -109,6 +160,7 @@ export function GlobalScripts({ location }: GlobalScriptsProps) { })(); } + // Production Pylon integration disabled - only enabled in dev } catch (_err) { // On error, assume logged in (do nothing) } @@ -116,8 +168,15 @@ export function GlobalScripts({ location }: GlobalScriptsProps) { `; return ( - + <> + {isDev && ( + + )} + + ); } From 3868ddd7d41491c4b2fdf05c28380b67dc63cd4a Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Wed, 22 Oct 2025 20:00:13 -0700 Subject: [PATCH 02/13] fix: pylon script can assume auth is only valid in dev --- src/components/GlobalScripts.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/GlobalScripts.tsx b/src/components/GlobalScripts.tsx index 76157d2..858dc6c 100644 --- a/src/components/GlobalScripts.tsx +++ b/src/components/GlobalScripts.tsx @@ -64,10 +64,6 @@ export function GlobalScripts({ location }: GlobalScriptsProps) { const scriptContent = ` (async function () { - // Skip on localhost (handled by separate local dev script) - var isLocalhost = typeof window !== 'undefined' && - (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); - if (isLocalhost) return; try { var response = await fetch('/api/auth/session', { credentials: 'include' }); @@ -160,7 +156,6 @@ export function GlobalScripts({ location }: GlobalScriptsProps) { })(); } - // Production Pylon integration disabled - only enabled in dev } catch (_err) { // On error, assume logged in (do nothing) } From 52ec6a11e427ad8ae152ebe9a3dd4519c7be5a27 Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Mon, 20 Oct 2025 21:54:08 -0700 Subject: [PATCH 03/13] wip: ask ai v2 --- .gitignore | 1 + bun.lock | 58 ++++- package.json | 4 + scripts/generate-llm-files.ts | 27 ++- scripts/generate-md-files.ts | 21 +- scripts/utils/progress.ts | 24 ++ src/ai/message-types.ts | 10 + src/ai/tools.ts | 133 +++++++++++ src/app/api/ai/route.ts | 233 ++++++++++++++++++ src/app/api/chat/route.ts | 44 ++++ src/app/global.css | 14 +- src/app/layout.tsx | 2 + src/components/ChatFAB.tsx | 33 +++ src/components/ChatMessage.tsx | 327 +++++++++++++++++++++++++ src/components/ChatSidebar.tsx | 411 ++++++++++++++++++++++++++++++++ src/components/ChatWidget.tsx | 37 +++ src/components/SearchDialog.tsx | 12 + src/components/ui/button.tsx | 49 ++++ src/hooks/useCurrentPageMd.ts | 57 +++++ src/hooks/useDialogState.ts | 73 ++++++ src/lib/local-store.ts | 98 ++++++++ src/lib/page-context.ts | 65 +++++ 22 files changed, 1707 insertions(+), 26 deletions(-) create mode 100644 scripts/utils/progress.ts create mode 100644 src/ai/message-types.ts create mode 100644 src/ai/tools.ts create mode 100644 src/app/api/ai/route.ts create mode 100644 src/app/api/chat/route.ts create mode 100644 src/components/ChatFAB.tsx create mode 100644 src/components/ChatMessage.tsx create mode 100644 src/components/ChatSidebar.tsx create mode 100644 src/components/ChatWidget.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/hooks/useCurrentPageMd.ts create mode 100644 src/hooks/useDialogState.ts create mode 100644 src/lib/local-store.ts create mode 100644 src/lib/page-context.ts diff --git a/.gitignore b/.gitignore index 1d4cfd1..79c756e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ wrangler.jsonc wrangler.ci.jsonc wrangler.local.jsonc src/lib/title-map.json +tsconfig.tsbuildinfo diff --git a/bun.lock b/bun.lock index e9e60a4..0c74dbd 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,12 @@ "": { "name": "@superwall-me/docs", "dependencies": { + "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/react": "^2.0.76", "@radix-ui/react-dialog": "^1.1.11", + "ai": "^5.0.76", "class-variance-authority": "^0.7.1", + "cli-progress": "^3.12.0", "clsx": "^2.1.0", "fumadocs-core": "15.3.2", "fumadocs-mdx": "<12.0.0", @@ -56,6 +60,16 @@ }, }, "packages": { + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12", "@vercel/oidc": "3.0.3" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GIkR3+Fyif516ftXv+YPSPstnAHhcZxNoR2s8uSHhQ1yBT7I7aQYTVwpjAuYoT3GR+TeP50q7onj2/nDRbT2FQ=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.12", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg=="], + + "@ai-sdk/react": ["@ai-sdk/react@2.0.76", "", { "dependencies": { "@ai-sdk/provider-utils": "3.0.12", "ai": "5.0.76", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.25.76 || ^4.1.8" }, "optionalPeers": ["zod"] }, "sha512-ggAPzyaKJTqUWigpxMzI5DuC0Y3iEpDUPCgz6/6CpnKZY/iok+x5xiZhDemeaP0ILw5IQekV0kdgBR8JPgI8zQ=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ast-grep/napi": ["@ast-grep/napi@0.35.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.35.0", "@ast-grep/napi-darwin-x64": "0.35.0", "@ast-grep/napi-linux-arm64-gnu": "0.35.0", "@ast-grep/napi-linux-arm64-musl": "0.35.0", "@ast-grep/napi-linux-x64-gnu": "0.35.0", "@ast-grep/napi-linux-x64-musl": "0.35.0", "@ast-grep/napi-win32-arm64-msvc": "0.35.0", "@ast-grep/napi-win32-ia32-msvc": "0.35.0", "@ast-grep/napi-win32-x64-msvc": "0.35.0" } }, "sha512-3ucaaSxV6fxXoqHrE/rxAvP1THnDdY5jNzGlnvx+JvnY9C/dSRKc0jlRMRz59N3El572+/yNRUUpAV1T9aBJug=="], @@ -380,6 +394,8 @@ "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.10.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.8.2", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "wrangler": "^4.38.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-hG9o9wjsgbshLkOEuX1EVQ1GhgbFv8VU/nI8atxgCgOHT87+ypsLOsVA4/6ivctpBZ/PLU/E9+b/eYZnqMVd/w=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@orama/orama": ["@orama/orama@3.1.15", "", {}, "sha512-ltjr1WHlY+uqEKE0JG2G6Xn36mSQGmPdPGQedQyipekBdf0iAtp8oL9dckQRX8cP+nUfOZwwPWSu7km8gGciUg=="], "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], @@ -650,6 +666,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/oidc": ["@vercel/oidc@3.0.3", "", {}, "sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -662,6 +680,8 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "ai": ["ai@5.0.76", "", { "dependencies": { "@ai-sdk/gateway": "2.0.0", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.12", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -738,6 +758,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], @@ -822,7 +844,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.234", "", {}, "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg=="], - "emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -880,6 +902,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], @@ -1110,6 +1134,8 @@ "json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -1544,7 +1570,7 @@ "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], - "string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1580,6 +1606,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="], + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], @@ -1590,6 +1618,8 @@ "terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -1658,6 +1688,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], @@ -2392,14 +2424,12 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tsx/esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], + "vfile-reporter/string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], + "wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2410,8 +2440,6 @@ "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.6.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA=="], @@ -2902,6 +2930,8 @@ "body-parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "cliui/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -3022,8 +3052,6 @@ "router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], @@ -3074,6 +3102,10 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + "vfile-reporter/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + + "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -3126,10 +3158,12 @@ "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "yargs/string-width/emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="], + "yargs/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@aws-sdk/client-dynamodb/@aws-crypto/sha256-js/@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -3280,6 +3314,8 @@ "foreground-child/cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/package.json b/package.json index 84b9572..853acfb 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,12 @@ "start": "next start" }, "dependencies": { + "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/react": "^2.0.76", "@radix-ui/react-dialog": "^1.1.11", + "ai": "^5.0.76", "class-variance-authority": "^0.7.1", + "cli-progress": "^3.12.0", "clsx": "^2.1.0", "fumadocs-core": "15.3.2", "fumadocs-mdx": "<12.0.0", diff --git a/scripts/generate-llm-files.ts b/scripts/generate-llm-files.ts index 9ecb3df..8c404a1 100644 --- a/scripts/generate-llm-files.ts +++ b/scripts/generate-llm-files.ts @@ -12,6 +12,7 @@ import remarkFollowExport from "../plugins/remark-follow-export" import remarkDirective from "remark-directive" import { remarkInclude } from 'fumadocs-mdx/config'; import remarkSdkFilter from "../plugins/remark-sdk-filter" +import { createProgressBar } from './utils/progress' // 1) Configure your plugins once const processor = remark() @@ -54,6 +55,8 @@ const filters = [ async function main() { await fs.mkdir(OUT, { recursive: true }) const allFiles = await walk(CONTENT) + const interactive = Boolean(process.stdout.isTTY) + const summaries: Array<{ name: string; count: number }> = [] for (const { name, suffix } of filters) { // apply your folder logic; e.g. filePath.includes('/ios/') @@ -67,6 +70,8 @@ async function main() { // build full and index const fullDocs = [] const indexDocs = [`# ${name === 'all' ? 'Superwall' : `Superwall ${name.toUpperCase()}`} SDK\n\n## Docs\n`] + const progressLabel = name === 'all' ? 'LLM all' : `LLM ${name}` + const progress = createProgressBar(progressLabel, subset.length) for (const filePath of subset) { const raw = await fs.readFile(filePath, 'utf8') @@ -83,23 +88,31 @@ async function main() { indexDocs.push( `- [${data.title}](${url}): ${data.description}` ) + progress?.increment() } + progress?.stop() + // write out await fs.writeFile(path.join(OUT, `llms-full${suffix}.txt`), fullDocs.join('\n\n---\n\n'), 'utf8') await fs.writeFile(path.join(OUT, `llms${suffix}.txt`), indexDocs.join('\n') + '\n\n## Optional\n\n- [GitHub](https://github.com/superwall)\n- [Twitter](https://twitter.com/superwall)\n- [Blog](https://superwall.com/blog)\n', 'utf8') - console.log(`✓ Generated llms-full${suffix}.txt & llms${suffix}.txt`) + summaries.push({ name, count: subset.length }) + + if (!interactive) { + console.log(`✓ Generated llms-full${suffix}.txt & llms${suffix}.txt (${subset.length} files)`) + } } -} -console.log('Starting LLM file generation...') + if (interactive) { + const totalDocs = allFiles.length + const variantCount = summaries.length + console.log(`✓ Generated LLM bundles (${variantCount} variants, ${totalDocs} docs)`) + } +} main() - .then(() => { - console.log('✨ Successfully generated all LLM files') - }) .catch(err => { console.error('❌ Error generating LLM files:') console.error(err.stack || err) process.exit(1) - }) \ No newline at end of file + }) diff --git a/scripts/generate-md-files.ts b/scripts/generate-md-files.ts index 92559db..d351fb8 100644 --- a/scripts/generate-md-files.ts +++ b/scripts/generate-md-files.ts @@ -12,6 +12,7 @@ import remarkFollowExport from "../plugins/remark-follow-export" import remarkDirective from "remark-directive" import { remarkInclude } from 'fumadocs-mdx/config'; import remarkSdkFilter from "../plugins/remark-sdk-filter" +import { createProgressBar } from './utils/progress' // Configure the processor with all plugins const processor = remark() @@ -49,6 +50,8 @@ function getRelativePath(filePath: string): string { async function main() { await fs.mkdir(OUT, { recursive: true }) const allFiles = await walk(CONTENT) + const interactive = Boolean(process.stdout.isTTY) + const progress = createProgressBar('MD export', allFiles.length) for (const filePath of allFiles) { try { @@ -76,21 +79,25 @@ ${data.description ? data.description + '\n\n' : ''}${cleanedContent}` // Write the .md file await fs.writeFile(outputPath, text, 'utf8') - console.log(`✓ Generated ${relativePath}.md`) + progress?.increment() + if (!interactive) { + console.log(`✓ Generated ${relativePath}.md`) + } } catch (error) { console.error(`❌ Error processing ${filePath}:`, error) } } -} -console.log('Starting MD file generation...') + progress?.stop() + + if (interactive) { + console.log(`✓ Exported ${allFiles.length} Markdown files`) + } +} main() - .then(() => { - console.log('✨ Successfully generated all MD files') - }) .catch(err => { console.error('❌ Error generating MD files:') console.error(err.stack || err) process.exit(1) - }) \ No newline at end of file + }) diff --git a/scripts/utils/progress.ts b/scripts/utils/progress.ts new file mode 100644 index 0000000..385dee4 --- /dev/null +++ b/scripts/utils/progress.ts @@ -0,0 +1,24 @@ +import cliProgress from 'cli-progress' + +export type ProgressBar = { + increment: () => void + stop: () => void +} | null + +export function createProgressBar(label: string, total: number): ProgressBar { + if (!process.stdout.isTTY || total === 0) { + return null + } + + const bar = new cliProgress.SingleBar({ + format: `${label.padEnd(18)} [{bar}] {value}/{total} ({percentage}%)`, + barCompleteChar: '█', + barIncompleteChar: '░', + clearOnComplete: true, + hideCursor: true, + linewrap: false, + }) + + bar.start(total, 0) + return bar +} diff --git a/src/ai/message-types.ts b/src/ai/message-types.ts new file mode 100644 index 0000000..b2b0b6b --- /dev/null +++ b/src/ai/message-types.ts @@ -0,0 +1,10 @@ +import type { UIMessage } from 'ai'; + +export type DocContext = { + url: string; + docId: string; + title?: string; + headings?: string[]; +}; + +export type AppMessage = UIMessage<{ doc?: DocContext }>; diff --git a/src/ai/tools.ts b/src/ai/tools.ts new file mode 100644 index 0000000..fc5ca44 --- /dev/null +++ b/src/ai/tools.ts @@ -0,0 +1,133 @@ +import { tool } from 'ai'; +import { z } from 'zod'; + +/** + * Tool: page.context + * Fetches bounded text slices of a specific doc page + */ +export const pageContextTool = tool({ + description: 'Fetch the full content of a specific documentation page. Use this when you need detailed information about a particular page.', + inputSchema: z.object({ + docId: z.string().describe('The document ID (e.g., "ios/quickstart") or full URL of the page to fetch'), + }), + execute: async ({ docId }) => { + try { + // docId can be either a path like "ios/quickstart" or a full URL + const isUrl = docId.startsWith('http'); + let mdPath: string; + + if (isUrl) { + // Extract path from URL + const urlObj = new URL(docId); + const pathname = urlObj.pathname; + mdPath = pathname.endsWith('/') ? `${pathname}index.md` : `${pathname}.md`; + } else { + mdPath = `/docs/${docId}.md`; + } + + // Fetch the markdown content + const response = await fetch(`https://docs.superwall.com${mdPath}`); + + if (!response.ok) { + return { error: `Failed to fetch page: ${response.status}` }; + } + + const content = await response.text(); + + // For now, return the full content + // TODO: Implement chunking, ranking, and token budgeting + return { + url: isUrl ? docId : `https://docs.superwall.com/docs/${docId}`, + docId: isUrl ? 'unknown' : docId, + content, + }; + } catch (error) { + return { error: `Failed to fetch page content: ${error}` }; + } + }, +}); + +/** + * Tool: mcp.search + * Search documentation via MCP endpoint + */ +export const mcpSearchTool = tool({ + description: 'Search the Superwall documentation using semantic search. Returns relevant documentation pages with snippets.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + }), + execute: async ({ query }) => { + try { + // Call MCP SSE endpoint + const mcpUrl = 'https://mcp.superwall.com/sse'; + + const response = await fetch(mcpUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + method: 'tools/call', + params: { + name: 'search_docs', + arguments: { + query, + }, + }, + }), + }); + + if (!response.ok) { + return { error: `MCP search failed: ${response.status}` }; + } + + const data = await response.json(); + + // Return results in compact format + return { + results: data.content?.[0]?.text || [], + query, + }; + } catch (error) { + return { error: `MCP search error: ${error}` }; + } + }, +}); + +/** + * Tool: docs.search + * Search using the existing docs search API (non-MCP) + */ +export const docsSearchTool = tool({ + description: 'Search the documentation using the built-in search API. Alternative to MCP search.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + }), + execute: async ({ query }) => { + try { + // Call the existing search API + const response = await fetch(`https://docs.superwall.com/api/search?q=${encodeURIComponent(query)}`); + + if (!response.ok) { + return { error: `Search failed: ${response.status}` }; + } + + const data = await response.json(); + + // Transform to match MCP search format + const results = data.map((item: any) => ({ + title: item.title, + url: item.url, + snippet: item.content || item.description, + score: item.score, + })); + + return { + results, + query, + }; + } catch (error) { + return { error: `Search error: ${error}` }; + } + }, +}); diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts new file mode 100644 index 0000000..0adf4eb --- /dev/null +++ b/src/app/api/ai/route.ts @@ -0,0 +1,233 @@ +import { streamText, convertToModelMessages } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import { pageContextTool, mcpSearchTool, docsSearchTool } from '@/ai/tools'; +import type { AppMessage } from '@/ai/message-types'; + +export const runtime = 'edge'; + +const systemPrompt = `You are a helpful AI assistant for Superwall documentation. + +Your role is to help users understand and implement Superwall's SDK across iOS, Android, Flutter, React Native, and Web. + +Guidelines: +- Always provide concise, implementation-ready answers and format them in markdown using short sections or lists. +- The user's current page path is included with every request—treat it as the primary context for your response. +- When information is missing, call the docs.search tool first (fall back to mcp.search if needed) before answering, and cite the sources you use. +- Use the page.context tool when you need to examine a specific documentation page in detail. +- Include code examples when they clarify the response, and highlight any platform-specific differences when relevant.`; + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { messages, currentPageContent, currentPagePath, currentPageUrl, debug: debugFromBody } = body as { + messages: AppMessage[]; + currentPageContent?: string; + currentPagePath?: string; + currentPageUrl?: string; + debug?: boolean; + }; + + let debug = Boolean(debugFromBody); + if (!debug && currentPageUrl) { + try { + const url = new URL(currentPageUrl); + const debugParam = url.searchParams.get('ai-debug'); + if (debugParam === '' || debugParam === '1' || debugParam === 'true') { + debug = true; + } + } catch (error) { + console.warn('Failed to parse current page URL for debug flag', error); + } + } + + if (!debug && process.env.AI_DEBUG?.toLowerCase() === 'true') { + debug = true; + } + + const getNow = () => + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + const startTime = getNow(); + const elapsed = () => `${Math.round(getNow() - startTime)}ms`; + const debugLog = (...args: unknown[]) => { + if (!debug) return; + const timestamp = new Date().toISOString(); + console.log(`[AI debug ${timestamp} +${elapsed()}]`, ...args); + }; + const summarizeUsage = (usage: any) => + usage + ? { + input: usage.inputTokens, + output: usage.outputTokens, + total: usage.totalTokens, + cachedInput: usage.cachedInputTokens, + } + : undefined; + + if (debug) { + debugLog('Debug mode enabled'); + debugLog('Request metadata', { + messageCount: messages?.length ?? 0, + lastMessageRole: messages?.[messages.length - 1]?.role, + currentPagePath, + }); + } + + // Basic validation + if (!messages || !Array.isArray(messages)) { + return new Response('Invalid messages format', { status: 400 }); + } + + // Limit message count (simple rate limiting) + if (messages.length > 100) { + return new Response('Too many messages', { status: 400 }); + } + + // Build light context from current page + let contextPrefix = ''; + if (currentPagePath) { + contextPrefix = `\n\n# Current Page Context\n\nThe user is currently viewing: ${currentPageUrl || currentPagePath}\n`; + + // If we have content, extract light metadata (title, headings) + if (currentPageContent) { + const titleMatch = currentPageContent.match(/^#\s+(.+)$/m); + const title = titleMatch?.[1]; + + const headingMatches = currentPageContent.matchAll(/^#{2,}\s+(.+)$/gm); + const headings = Array.from(headingMatches) + .map(match => match[1]) + .slice(0, 6); + + if (title) { + contextPrefix += `Page title: ${title}\n`; + } + + if (headings.length > 0) { + contextPrefix += `Page sections: ${headings.join(', ')}\n`; + } + } + + contextPrefix += '\n---\n'; + } + + // Inject context into system prompt + const enhancedSystemPrompt = systemPrompt + contextPrefix; + + // Get OpenAI API key from env + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return new Response('OpenAI API key not configured', { status: 500 }); + } + + // Stream the response using AI SDK + const openai = createOpenAI({ apiKey }); + + const streamOptions: Parameters[0] = { + model: openai('gpt-5'), + system: enhancedSystemPrompt, + messages: convertToModelMessages(messages), + tools: { + page_context: pageContextTool, + mcp_search: mcpSearchTool, + docs_search: docsSearchTool, + }, + stopWhen: [], + }; + + if (debug) { + streamOptions.onChunk = async ({ chunk }) => { + const textDelta = 'delta' in chunk ? (chunk as { delta: string }).delta : (chunk as { text?: string }).text; + switch (chunk.type) { + case 'text-delta': + debugLog('Chunk:text', { id: chunk.id, delta: textDelta }); + break; + case 'reasoning-delta': + debugLog('Chunk:reasoning', { id: chunk.id, delta: textDelta }); + break; + case 'source': + debugLog('Chunk:source', chunk); + break; + case 'tool-call': + debugLog('Chunk:tool-call', { + toolName: chunk.toolName, + input: chunk.input, + providerExecuted: chunk.providerExecuted, + dynamic: chunk.dynamic, + invalid: chunk.invalid, + error: chunk.error, + }); + break; + case 'tool-input-start': + debugLog('Chunk:tool-input-start', { + toolName: chunk.toolName, + toolCallId: (chunk as { toolCallId?: string }).toolCallId ?? chunk.id, + }); + break; + case 'tool-input-delta': + debugLog('Chunk:tool-input-delta', { + toolCallId: (chunk as { toolCallId?: string }).toolCallId ?? chunk.id, + delta: (chunk as { inputTextDelta?: string }).inputTextDelta ?? textDelta, + }); + break; + case 'tool-result': + debugLog('Chunk:tool-result', { + toolName: chunk.toolName, + toolCallId: chunk.toolCallId, + output: chunk.output, + }); + break; + case 'raw': + debugLog('Chunk:raw', chunk.rawValue); + break; + default: + debugLog('Chunk:unhandled', chunk); + } + }; + + streamOptions.onStepFinish = async step => { + debugLog('Step finished', { + finishReason: step.finishReason, + textPreview: step.text.slice(0, 120), + toolCalls: step.toolCalls.map(call => call.toolName), + usage: summarizeUsage(step.usage), + }); + }; + + streamOptions.onFinish = async event => { + debugLog('Stream finished', { + finishReason: event.finishReason, + totalUsage: summarizeUsage(event.totalUsage), + stepCount: event.steps.length, + }); + }; + + streamOptions.onError = async ({ error }) => { + debugLog('Stream error', error); + }; + } + + const result = streamText(streamOptions); + + if (debug) { + result.response + .then(response => { + debugLog('Response metadata', { + messageCount: response.messages.length, + }); + debugLog('Response messages', response.messages); + }) + .catch(error => { + debugLog('Response metadata error', error); + }); + } + + return result.toUIMessageStreamResponse(); + } catch (error) { + console.error('AI route error:', error); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..3d060e6 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server'; + +const API_URL = + process.env.NODE_ENV === 'development' + ? 'http://localhost:8787' + : 'https://docs-ai-api.superwall.com'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // Forward the request to the docs-ai-api worker + const response = await fetch(`${API_URL}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + // Return the streaming response + return new Response(response.body, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'text/plain', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + console.error('Chat API proxy error:', error); + return new Response( + JSON.stringify({ error: 'Failed to process chat request' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} diff --git a/src/app/global.css b/src/app/global.css index f567daa..24bc246 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -20,6 +20,7 @@ */ --radius-lg: 0.75rem; --spacing: 0.3rem; + --sw-chat-width: 0px; } .dark { @@ -55,6 +56,17 @@ --tw-prose-counters: var(--color-fd-muted-foreground); } +body { + padding-right: var(--sw-chat-width); + transition: padding-right 0.2s ease; +} + +@media (max-width: 1023px) { + body { + padding-right: 0 !important; + } +} + /* colored pill on first column for */ .fd-param tbody td:first-child code { background: #74F8F020; @@ -74,4 +86,4 @@ [data-role="expand"], svg[data-icon="true"] { /* ← arrow in current Fumadocs build */ pointer-events: none; -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d2563d0..33b8eb5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Inter } from 'next/font/google'; import type { ReactNode } from 'react'; import { SearchDialogWrapper as SearchDialog } from '../components/SearchDialog'; import { GlobalScripts } from '../components/GlobalScripts'; +import { ChatWidget } from '../components/ChatWidget'; const inter = Inter({ subsets: ['latin'], @@ -25,6 +26,7 @@ export default function Layout({ children }: { children: ReactNode }) { }} > {children} + diff --git a/src/components/ChatFAB.tsx b/src/components/ChatFAB.tsx new file mode 100644 index 0000000..c53cf5b --- /dev/null +++ b/src/components/ChatFAB.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Sparkles } from 'lucide-react'; +import { cn } from 'fumadocs-ui/utils/cn'; + +interface ChatFABProps { + onClick: () => void; + isOpen: boolean; +} + +export function ChatFAB({ onClick, isOpen }: ChatFABProps) { + if (isOpen) return null; + + return ( + + ); +} diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx new file mode 100644 index 0000000..4b0927e --- /dev/null +++ b/src/components/ChatMessage.tsx @@ -0,0 +1,327 @@ +'use client'; + +import type { UIMessage } from 'ai'; +import React, { useCallback, useMemo, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { cn } from 'fumadocs-ui/utils/cn'; +import { + Brain, + Check, + Copy, + Loader2, + TriangleAlert, + Workflow, + ThumbsDown, + ThumbsUp, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +type ToolLikePart = { + type: string; + state?: string; + toolName?: string; + toolCallId?: string; + input?: unknown; + output?: unknown; + errorText?: unknown; + preliminary?: boolean; +}; + +const TOOL_PREFIX = 'tool-'; + +const extractText = (message: UIMessage) => + message.parts + .filter(part => part.type === 'text') + .map(part => ('text' in part ? part.text : '')) + .join('') + .trim(); + +const extractReasoning = (message: UIMessage) => + message.parts + .filter(part => part.type === 'reasoning') + .map(part => ('text' in part ? part.text : '')) + .join('\n') + .trim(); + +const extractToolParts = (message: UIMessage) => + message.parts.filter(part => { + if (!part || typeof part !== 'object') { + return false; + } + + const type = (part as { type?: string }).type; + return type === 'dynamic-tool' || (type ? type.startsWith(TOOL_PREFIX) : false); + }) as ToolLikePart[]; + +const getToolName = (part: ToolLikePart) => { + if (part.type === 'dynamic-tool') { + return part.toolName ?? 'tool'; + } + + return part.type.startsWith(TOOL_PREFIX) + ? part.type.slice(TOOL_PREFIX.length) + : 'tool'; +}; + +const isToolRunning = (part: ToolLikePart) => + part.state === 'input-streaming' || part.state === 'input-available'; + +const isToolComplete = (part: ToolLikePart) => + part.state === 'output-available' || part.state === 'output-error'; + +const summariseOutput = (output: unknown) => { + if (output == null) return null; + + if (typeof output === 'string') { + return output.length > 320 ? `${output.slice(0, 317)}…` : output; + } + + try { + const asJson = JSON.stringify(output, null, 2); + return asJson.length > 320 ? `${asJson.slice(0, 317)}…` : asJson; + } catch (error) { + return String(output); + } +}; + +interface ChatMessageProps { + message: UIMessage; + onFeedback?: (rating: 'positive' | 'negative', comment?: string) => void; +} + +export function ChatMessage({ message, onFeedback }: ChatMessageProps) { + const role = message.role; + + const [feedback, setFeedback] = useState<'positive' | 'negative' | null>(null); + const [showCommentInput, setShowCommentInput] = useState(false); + const [comment, setComment] = useState(''); + const [copied, setCopied] = useState(false); + + const textContent = useMemo(() => extractText(message), [message]); + const reasoningContent = useMemo(() => extractReasoning(message), [message]); + const toolParts = useMemo(() => extractToolParts(message), [message]); + + const hasToolInFlight = toolParts.some(part => isToolRunning(part)); + const isStreamingText = message.parts.some( + part => part.type === 'text' && 'state' in part && part.state === 'streaming' + ); + const showThinkingPlaceholder = + role === 'assistant' && !textContent && (isStreamingText || hasToolInFlight); + + const handleFeedback = (rating: 'positive' | 'negative') => { + if (feedback === rating) { + setFeedback(null); + setShowCommentInput(false); + return; + } + + setFeedback(rating); + setShowCommentInput(true); + }; + + const submitFeedback = () => { + if (feedback && onFeedback) { + onFeedback(feedback, comment || undefined); + setShowCommentInput(false); + } + }; + + const handleCopy = useCallback(async () => { + const textToCopy = [textContent, reasoningContent] + .filter(Boolean) + .join('\n\n') + .trim(); + + if (!textToCopy) return; + + try { + await navigator.clipboard.writeText(textToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy response', error); + } + }, [textContent, reasoningContent]); + + if (role === 'system') { + return null; + } + + if (role === 'user') { + const userText = textContent || '…'; + + return ( +
+
+

{userText}

+
+
+ ); + } + + return ( +
+
+
+ {toolParts.length > 0 && ( +
+ {toolParts.map((part, index) => { + const toolName = getToolName(part); + const isRunning = isToolRunning(part); + const isComplete = isToolComplete(part); + + const Icon = (() => { + if (isRunning) return Workflow; + if (part.state === 'output-error') return TriangleAlert; + return Check; + })(); + + const statusLabel = (() => { + switch (part.state) { + case 'input-streaming': + return `Preparing ${toolName}…`; + case 'input-available': + return `Calling ${toolName}…`; + case 'output-available': + return `${toolName} finished`; + case 'output-error': + return `${toolName} failed`; + default: + return `${toolName}`; + } + })(); + + const outputPreview = summariseOutput(part.output); + + return ( +
+
+ {isRunning ? ( + + ) : ( + + )} + {statusLabel} + {part.preliminary && ( + preview + )} +
+ + {outputPreview && ( +
+ {outputPreview} +
+ )} + + {part.state === 'output-error' && ( +
+ {typeof part.errorText === 'string' + ? part.errorText + : 'The tool reported an error.'} +
+ )} + + {!isComplete && !isRunning && ( +
+ Awaiting response… +
+ )} +
+ ); + })} +
+ )} + + {showThinkingPlaceholder && ( +
+ + Thinking… +
+ )} + + {textContent && {textContent}} + + {reasoningContent && ( +
+ Show reasoning +
+                {reasoningContent}
+              
+
+ )} +
+
+ + {(onFeedback || textContent) && ( +
+ {textContent && ( + + )} + + {onFeedback && ( +
+ + +
+ )} + + {showCommentInput && ( +
+ setComment(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + submitFeedback(); + } + }} + className="flex-1 rounded border border-fd-border bg-fd-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-fd-primary" + /> + +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/ChatSidebar.tsx b/src/components/ChatSidebar.tsx new file mode 100644 index 0000000..8351bbe --- /dev/null +++ b/src/components/ChatSidebar.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { Loader2, RotateCcw, Send, X } from 'lucide-react'; +import { cn } from 'fumadocs-ui/utils/cn'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { ChatMessage } from './ChatMessage'; +import { useCurrentPageMd } from '@/hooks/useCurrentPageMd'; +import { clearMessages, loadMessages, saveMessages } from '@/lib/local-store'; + +interface ChatSidebarProps { + isOpen: boolean; + onClose: () => void; +} + +const WIDTH_STORAGE_KEY = 'chat:sidebar-width'; +const DEFAULT_WIDTH = 420; +const MIN_WIDTH = 320; +const MAX_WIDTH = 720; + +const computeMaxWidth = (viewportWidth: number) => + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, viewportWidth - 80)); + +const clampWidth = (value: number, viewportWidth: number) => { + const maxWidth = computeMaxWidth(viewportWidth); + return Math.min(Math.max(value, MIN_WIDTH), maxWidth); +}; + +export function ChatSidebar({ isOpen, onClose }: ChatSidebarProps) { + const { content: pageContent, pathname } = useCurrentPageMd(); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const latestContextRef = useRef({ content: pageContent, pathname }); + + const [input, setInput] = useState(''); + const [initialMessages] = useState(() => loadMessages()); + const [isDesktop, setIsDesktop] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [width, setWidth] = useState(() => { + if (typeof window === 'undefined') { + return DEFAULT_WIDTH; + } + + const stored = window.localStorage.getItem(WIDTH_STORAGE_KEY); + const parsed = stored ? Number.parseInt(stored, 10) : DEFAULT_WIDTH; + return clampWidth(Number.isNaN(parsed) ? DEFAULT_WIDTH : parsed, window.innerWidth); + }); + + const widthRef = useRef(width); + useEffect(() => { + widthRef.current = width; + }, [width]); + + useEffect(() => { + latestContextRef.current = { content: pageContent, pathname }; + }, [pageContent, pathname]); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const updateIsDesktop = () => { + setIsDesktop(window.innerWidth >= 1024); + }; + + updateIsDesktop(); + window.addEventListener('resize', updateIsDesktop); + return () => window.removeEventListener('resize', updateIsDesktop); + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(WIDTH_STORAGE_KEY, String(width)); + }, [width]); + + useEffect(() => { + if (typeof document === 'undefined') return; + + const root = document.documentElement; + if (isOpen && isDesktop) { + root.style.setProperty('--sw-chat-width', `${width}px`); + } else { + root.style.setProperty('--sw-chat-width', '0px'); + } + + return () => { + root.style.setProperty('--sw-chat-width', '0px'); + }; + }, [isOpen, isDesktop, width]); + + useEffect(() => { + if (isResizing) { + document.body.style.cursor = 'col-resize'; + } else { + document.body.style.cursor = ''; + } + }, [isResizing]); + + const transport = useMemo( + () => + new DefaultChatTransport({ + api: '/docs/api/ai', + body: () => { + const { content, pathname: currentPath } = latestContextRef.current; + const href = typeof window !== 'undefined' ? window.location.href : undefined; + + let debug = false; + if (typeof window !== 'undefined') { + try { + const params = new URLSearchParams(window.location.search); + debug = params.has('ai-debug') || window.localStorage.getItem('sw-ai-debug') === '1'; + } catch (error) { + console.warn('Failed to evaluate AI debug flag on client', error); + } + } + + return { + currentPageContent: content ?? undefined, + currentPagePath: currentPath ?? undefined, + currentPageUrl: href, + debug: debug || undefined, + }; + }, + }), + [] + ); + + const { + messages, + sendMessage, + status, + setMessages, + } = useChat({ + transport, + id: 'superwall-ai-chat-v2', + messages: initialMessages, + onError: (error) => { + console.error('Chat error:', error); + }, + onFinish: ({ messages: finishedMessages, isError }) => { + if (!isError) { + saveMessages(finishedMessages); + } + }, + }); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + useEffect(() => { + if (!isOpen) return undefined; + + const timer = window.setTimeout(() => { + inputRef.current?.focus(); + }, 120); + + return () => window.clearTimeout(timer); + }, [isOpen]); + + const handleFeedback = useCallback( + async ( + messageId: string, + rating: 'positive' | 'negative', + comment?: string + ) => { + try { + const message = messages.find((m) => m.id === messageId); + if (!message) return; + + const extractText = (msg: typeof message) => + msg.parts.map(p => (p.type === 'text' ? p.text : '')).join(''); + + const prevMessage = messages.find((m, i) => messages[i + 1]?.id === messageId); + + await fetch('/docs/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'ai', + question: prevMessage ? extractText(prevMessage) : '', + answer: extractText(message), + rating, + comment, + }), + }); + } catch (error) { + console.error('Failed to send feedback:', error); + } + }, + [messages] + ); + + const clearChat = useCallback(() => { + if (confirm('Reset the current conversation?')) { + setMessages([]); + clearMessages(); + } + }, [setMessages]); + + useEffect(() => { + if (messages.length > 0) { + saveMessages(messages); + } else { + clearMessages(); + } + }, [messages]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!input.trim() || status !== 'ready') return; + + sendMessage({ text: input.trim() }); + setInput(''); + }; + + const startResize = (event: React.MouseEvent) => { + if (!isDesktop) return; + event.preventDefault(); + setIsResizing(true); + + const startX = event.clientX; + const startWidth = widthRef.current; + + const onMouseMove = (moveEvent: MouseEvent) => { + const delta = startX - moveEvent.clientX; + const viewportWidth = window.innerWidth; + const nextWidth = clampWidth(startWidth + delta, viewportWidth); + setWidth(nextWidth); + }; + + const onMouseUp = () => { + setIsResizing(false); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }; + + const resetWidth = () => { + if (typeof window === 'undefined') return; + const viewportWidth = window.innerWidth; + setWidth(clampWidth(DEFAULT_WIDTH, viewportWidth)); + }; + + const isLoading = status === 'submitted' || status === 'streaming'; + const pendingToolNames = useMemo(() => { + const names = new Set(); + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message.role !== 'assistant') continue; + + message.parts.forEach(part => { + if (!part || typeof part !== 'object') return; + const type = (part as { type?: string }).type; + if (!type) return; + + const state = (part as { state?: string }).state; + const isToolPart = type === 'dynamic-tool' || type.startsWith('tool-'); + if (!isToolPart) return; + if (state === 'output-available' || state === 'output-error') return; + + const toolName = + type === 'dynamic-tool' + ? (part as { toolName?: string }).toolName ?? 'tool' + : type.replace(/^tool-/, ''); + names.add(toolName); + }); + + break; + } + + return Array.from(names); + }, [messages]); + + const showSpinner = isLoading || pendingToolNames.length > 0; + const spinnerLabel = pendingToolNames.length > 0 + ? `Running ${pendingToolNames.join(', ')}` + : status === 'submitted' + ? 'Sending…' + : 'Thinking…'; + + const sidebarStyle = isDesktop + ? { width: `${width}px`, minWidth: `${MIN_WIDTH}px`, maxWidth: `${MAX_WIDTH}px` } + : undefined; + + return ( + <> + {isOpen && ( +
+ )} + +
+ }> - - - - - ); -} \ No newline at end of file +function buildQueryString(searchParams: SearchParams) { + const params = new URLSearchParams(); + + Object.entries(searchParams ?? {}).forEach(([key, value]) => { + if (typeof value === 'string') { + params.append(key, value); + } else if (Array.isArray(value)) { + value.forEach((entry) => { + if (typeof entry === 'string') { + params.append(key, entry); + } + }); + } + }); + + const query = params.toString(); + return query ? `?${query}` : ''; +} + +export default async function AIPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const resolved = await searchParams; + redirect(`/docs/ai${buildQueryString(resolved)}`); +} diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts index 0adf4eb..68ef261 100644 --- a/src/app/api/ai/route.ts +++ b/src/app/api/ai/route.ts @@ -1,20 +1,29 @@ import { streamText, convertToModelMessages } from 'ai'; import { createOpenAI } from '@ai-sdk/openai'; -import { pageContextTool, mcpSearchTool, docsSearchTool } from '@/ai/tools'; +import { pageContextTool, mcpSearchTool } from '@/ai/tools'; import type { AppMessage } from '@/ai/message-types'; export const runtime = 'edge'; -const systemPrompt = `You are a helpful AI assistant for Superwall documentation. +const systemPrompt = `You are an AI assistant for the Superwall documentation. -Your role is to help users understand and implement Superwall's SDK across iOS, Android, Flutter, React Native, and Web. +**Response Format:** +- Keep answers concise: you live in a chat sidebar, so you need to be concise and to the point +- Use markdown formatting when appropriate: lists, code blocks, and headings +- Include direct links to docs pages when helpful (always in markdown format: [link text](https://superwall.com/docs/path), never just the raw URL) +- Always lead with the answer, no preamble. Users can ask follow-up questions if they need more information. -Guidelines: -- Always provide concise, implementation-ready answers and format them in markdown using short sections or lists. -- The user's current page path is included with every request—treat it as the primary context for your response. -- When information is missing, call the docs.search tool first (fall back to mcp.search if needed) before answering, and cite the sources you use. -- Use the page.context tool when you need to examine a specific documentation page in detail. -- Include code examples when they clarify the response, and highlight any platform-specific differences when relevant.`; +**Context & Tools:** +- User's current page is primary context +- Use mcp_search to search documentation when needed (semantic search powered by embeddings) +- Use page_context for specific page details +- Cite sources by linking to doc pages with markdown formatting +- **Important**: If a tool fails, only retry it ONCE with a modified approach. If it fails again, move on and work with available information. + +**Code Examples:** +- Include when helpful, keep concise +- Note platform differences when relevant +- Prefer inline code for small snippets`; export async function POST(req: Request) { try { @@ -55,15 +64,9 @@ export async function POST(req: Request) { const timestamp = new Date().toISOString(); console.log(`[AI debug ${timestamp} +${elapsed()}]`, ...args); }; - const summarizeUsage = (usage: any) => - usage - ? { - input: usage.inputTokens, - output: usage.outputTokens, - total: usage.totalTokens, - cachedInput: usage.cachedInputTokens, - } - : undefined; + // Track tool execution times and tokens + const toolMetrics = new Map(); + const stepMetrics: Array<{ type: string; duration: number; tokens: number }> = []; if (debug) { debugLog('Debug mode enabled'); @@ -130,76 +133,69 @@ export async function POST(req: Request) { tools: { page_context: pageContextTool, mcp_search: mcpSearchTool, - docs_search: docsSearchTool, }, stopWhen: [], }; if (debug) { streamOptions.onChunk = async ({ chunk }) => { - const textDelta = 'delta' in chunk ? (chunk as { delta: string }).delta : (chunk as { text?: string }).text; switch (chunk.type) { - case 'text-delta': - debugLog('Chunk:text', { id: chunk.id, delta: textDelta }); - break; - case 'reasoning-delta': - debugLog('Chunk:reasoning', { id: chunk.id, delta: textDelta }); - break; - case 'source': - debugLog('Chunk:source', chunk); - break; - case 'tool-call': - debugLog('Chunk:tool-call', { - toolName: chunk.toolName, - input: chunk.input, - providerExecuted: chunk.providerExecuted, - dynamic: chunk.dynamic, - invalid: chunk.invalid, - error: chunk.error, - }); - break; case 'tool-input-start': - debugLog('Chunk:tool-input-start', { - toolName: chunk.toolName, - toolCallId: (chunk as { toolCallId?: string }).toolCallId ?? chunk.id, - }); - break; - case 'tool-input-delta': - debugLog('Chunk:tool-input-delta', { - toolCallId: (chunk as { toolCallId?: string }).toolCallId ?? chunk.id, - delta: (chunk as { inputTextDelta?: string }).inputTextDelta ?? textDelta, - }); + { + const toolCallId = (chunk as { toolCallId?: string }).toolCallId ?? chunk.id; + toolMetrics.set(toolCallId, { startTime: getNow() }); + } break; case 'tool-result': - debugLog('Chunk:tool-result', { - toolName: chunk.toolName, - toolCallId: chunk.toolCallId, - output: chunk.output, - }); + { + const toolCallId = chunk.toolCallId; + const metric = toolMetrics.get(toolCallId); + if (metric) { + const duration = getNow() - metric.startTime; + toolMetrics.delete(toolCallId); + + // Extract result info + let resultInfo = ''; + const output = chunk.output; + if (output && typeof output === 'object') { + if ('error' in output) { + resultInfo = ` error: ${output.error}`; + } else if ('results' in output && Array.isArray(output.results)) { + resultInfo = ` ${output.results.length} results`; + } else if ('content' in output) { + resultInfo = ' page loaded'; + } + } + + console.log(`called ${chunk.toolName}, ${(duration / 1000).toFixed(1)}s${resultInfo}`); + } + } break; - case 'raw': - debugLog('Chunk:raw', chunk.rawValue); - break; - default: - debugLog('Chunk:unhandled', chunk); } }; streamOptions.onStepFinish = async step => { - debugLog('Step finished', { - finishReason: step.finishReason, - textPreview: step.text.slice(0, 120), - toolCalls: step.toolCalls.map(call => call.toolName), - usage: summarizeUsage(step.usage), - }); + const usage = step.usage; + const totalTokens = usage?.totalTokens ?? 0; + const stepDuration = getNow() - (stepMetrics.length > 0 + ? stepMetrics.reduce((sum, s) => sum + s.duration, 0) + : 0); + + if (step.toolCalls.length > 0) { + // This was a tool-calling step + stepMetrics.push({ type: 'tool-calls', duration: stepDuration, tokens: totalTokens }); + } else if (step.text) { + // This was a thinking/response step + const stepType = step.reasoning ? 'thinking' : 'response'; + console.log(`${stepType}, ${(stepDuration / 1000).toFixed(1)}s, ${totalTokens} tokens`); + stepMetrics.push({ type: stepType, duration: stepDuration, tokens: totalTokens }); + } }; streamOptions.onFinish = async event => { - debugLog('Stream finished', { - finishReason: event.finishReason, - totalUsage: summarizeUsage(event.totalUsage), - stepCount: event.steps.length, - }); + const totalDuration = getNow() - startTime; + const totalTokens = event.totalUsage?.totalTokens ?? 0; + console.log(`\ntotal: ${(totalDuration / 1000).toFixed(1)}s, ${totalTokens} tokens`); }; streamOptions.onError = async ({ error }) => { @@ -209,19 +205,6 @@ export async function POST(req: Request) { const result = streamText(streamOptions); - if (debug) { - result.response - .then(response => { - debugLog('Response metadata', { - messageCount: response.messages.length, - }); - debugLog('Response messages', response.messages); - }) - .catch(error => { - debugLog('Response metadata error', error); - }); - } - return result.toUIMessageStreamResponse(); } catch (error) { console.error('AI route error:', error); diff --git a/src/app/global.css b/src/app/global.css index 24bc246..51d9562 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -58,7 +58,7 @@ body { padding-right: var(--sw-chat-width); - transition: padding-right 0.2s ease; + transition: padding-right 0.3s ease; } @media (max-width: 1023px) { @@ -67,6 +67,50 @@ body { } } +html.chat-open { + --fd-toc-width: 0px; + --fd-tocnav-height: 0px; +} + +#nd-toc, +#nd-tocnav { + transition: opacity 0.3s ease, transform 0.3s ease; + will-change: opacity, transform; +} + +#nd-toc { + overflow: hidden; + will-change: transform; + width: var(--fd-toc-width, 286px); + max-width: var(--fd-toc-width, 286px); + flex: 0 0 var(--fd-toc-width, 286px); +} + +#nd-toc > div { + transition: padding 0.3s ease; +} + +html.chat-open .xl\[--fd-toc-width\:286px\] { + --fd-toc-width: 0px !important; +} + +html.chat-open #nd-toc, +html.chat-open #nd-tocnav { + opacity: 0; + pointer-events: none; +} +html.chat-open #nd-toc { + transform: translateX(12px); + width: 0 !important; + max-width: 0 !important; + flex: 0 0 0 !important; + margin: 0 !important; +} + +html.chat-open #nd-toc > div { + padding: 0 !important; +} + /* colored pill on first column for */ .fd-param tbody td:first-child code { background: #74F8F020; diff --git a/src/app/support/page.tsx b/src/app/support/page.tsx new file mode 100644 index 0000000..59c875e --- /dev/null +++ b/src/app/support/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function SupportPage() { + const router = useRouter(); + const [isLoggedIn, setIsLoggedIn] = useState(true); + + // Fetch user session to check if logged in + useEffect(() => { + fetch('/api/auth/session') + .then((res) => res.json()) + .then((data) => { + if (data && typeof data.isLoggedIn === 'boolean') { + setIsLoggedIn(data.isLoggedIn); + } + }) + .catch((err) => { + console.error('Failed to fetch session:', err); + // Default to logged in on error + setIsLoggedIn(true); + }); + }, []); + + useEffect(() => { + // Check if in development mode + const isDev = process.env.NEXTJS_ENV === 'development' || process.env.NODE_ENV === 'development'; + + // Always allow in dev, otherwise check auth + if (!isDev && !isLoggedIn) { + // Redirect to login + window.location.href = '/api/auth/login'; + return; + } + + // Wait for Pylon to load and then open it + const checkPylon = setInterval(() => { + if (typeof window.Pylon === 'function') { + clearInterval(checkPylon); + try { + window.Pylon('show'); + // Redirect back to previous page or home + router.back(); + } catch (error) { + console.error('Error opening Pylon:', error); + router.push('/'); + } + } + }, 100); + + // Timeout after 5 seconds + const timeout = setTimeout(() => { + clearInterval(checkPylon); + console.error('Pylon failed to load'); + router.push('/'); + }, 5000); + + return () => { + clearInterval(checkPylon); + clearTimeout(timeout); + }; + }, [router, isLoggedIn]); + + return ( +
+
+

Opening support chat...

+
+
+ ); +} diff --git a/src/components/AskAI.tsx b/src/components/AskAI.tsx deleted file mode 100644 index 8451991..0000000 --- a/src/components/AskAI.tsx +++ /dev/null @@ -1,574 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef, ComponentProps } from 'react'; -import { Loader2 as Loader, Sparkles, RotateCcw, CornerDownLeft, X, ThumbsUp, ThumbsDown, ChevronDown } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import { cn } from 'fumadocs-ui/utils/cn'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; - -const API_URL = - typeof window !== 'undefined' && window.location.hostname.includes('localhost') - ? 'http://localhost:8787' - : 'https://docs-ai-api.superwall.com'; - -const USE_DUMMY_API = false; -const FORCE_ERROR_STATE = false; - -const SDK_OPTIONS = [ - { value: '', label: 'None' }, - { value: 'ios', label: 'iOS' }, - { value: 'android', label: 'Android' }, - { value: 'flutter', label: 'Flutter' }, - { value: 'expo', label: 'Expo' }, -] as const; - -const DUMMY_MARKDOWN = ` -### Lorem Ipsum - -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -`; - -type ChatHistoryItem = { - id: string; - question: string; - answer: string; - isError?: boolean; - isIncomplete?: boolean; - feedback?: { - rating: 'positive' | 'negative'; - comment?: string; - submitted?: boolean; - }; -}; - -interface AskAIProps extends ComponentProps<'div'> { - initialQuery?: string | null; -} - -export default function AskAI({ - className, - initialQuery, - ...props -}: AskAIProps) { - const [query, setQuery] = useState(''); - const [answerMd, setAnswerMd] = useState(null); - const [loading, setLoading] = useState(false); - const [history, setHistory] = useLocalStorage('superwall-ai-chat-history', []); - const [selectedSdk, setSelectedSdk] = useLocalStorage('superwall-ai-selected-sdk', ''); - const [showSdkDropdown, setShowSdkDropdown] = useState(false); - const [userEmail, setUserEmail] = useState(null); - - // Effect to migrate old data without IDs - useEffect(() => { - setHistory(prev => prev.map((item, index) => - item.id ? item : { ...item, id: `migrated-${Date.now()}-${index}` } - )); - }, []); - - // Effect to fetch user session - useEffect(() => { - fetch('/api/auth/session') - .then(res => res.json()) - .then(data => { - if (data.isLoggedIn && data.userInfo?.email) { - setUserEmail(data.userInfo.email); - } - }) - .catch(err => console.error('Failed to fetch session:', err)); - }, []); - const [currentQuestion, setCurrentQuestion] = useState(null); - const [retryQuery, setRetryQuery] = useState(null); - const [showAutofillPill, setShowAutofillPill] = useState(false); - const [feedbackState, setFeedbackState] = useState<{[key: string]: {showInput: boolean, comment: string}}>({}); - const inputRef = useRef(null); - const dropdownRef = useRef(null); - - const [focused, setFocused] = useState(false); - - const removeHistoryItem = (index: number) => { - setHistory(prev => prev.filter((_, i) => i !== index)); - }; - - const selectSdk = (sdkValue: string) => { - setSelectedSdk(sdkValue); - setShowSdkDropdown(false); - }; - - const getSelectedSdk = () => { - const found = SDK_OPTIONS.find(opt => opt.value === selectedSdk); - return found || SDK_OPTIONS[0]; // Default to "None" (first option) - }; - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowSdkDropdown(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const retryQuestion = (question: string, index: number) => { - // Remove the failed item and set retry flag - removeHistoryItem(index); - setRetryQuery(question); - }; - - const handleFeedback = (cardId: string, rating: 'positive' | 'negative') => { - const item = history.find(h => h.id === cardId); - - // Don't allow changes if feedback has been submitted - if (item?.feedback?.submitted) { - return; - } - - // If clicking the same rating again, undo it (only if not submitted) - if (item?.feedback?.rating === rating && !item.feedback.comment) { - setHistory(prev => prev.map(item => - item.id === cardId ? { ...item, feedback: undefined } : item - )); - setFeedbackState(prev => ({ - ...prev, - [cardId]: { showInput: false, comment: '' } - })); - return; - } - - // Update the rating (allow switching between positive/negative) - setHistory(prev => prev.map(item => - item.id === cardId ? { ...item, feedback: { rating, comment: feedbackState[cardId]?.comment || '' } } : item - )); - - // Show comment input for this item - setFeedbackState(prev => ({ - ...prev, - [cardId]: { showInput: true, comment: '' } - })); - - // Don't send to API yet - wait for user to submit - }; - - const submitFeedbackComment = (cardId: string) => { - const comment = feedbackState[cardId]?.comment || ''; - const item = history.find(h => h.id === cardId); - - if (!item?.feedback) return; - - // Update the history item with the comment and mark as submitted - setHistory(prev => prev.map(item => - item.id === cardId && item.feedback ? - { ...item, feedback: { ...item.feedback, comment, submitted: true } } : item - )); - - // Hide the input - setFeedbackState(prev => ({ - ...prev, - [cardId]: { ...prev[cardId], showInput: false } - })); - - // Send feedback with comment to API - sendFeedbackToAPI(cardId, item.feedback.rating, comment); - }; - - const updateFeedbackComment = (cardId: string, comment: string) => { - setFeedbackState(prev => ({ - ...prev, - [cardId]: { ...prev[cardId], comment } - })); - }; - - const sendFeedbackToAPI = async (cardId: string, rating: 'positive' | 'negative', comment: string) => { - const item = history.find(h => h.id === cardId); - if (!item) return; - - try { - const response = await fetch('/docs/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'ai', - question: item.question, - answer: item.answer, - rating, - comment: comment || undefined, - email: userEmail || undefined, - }), - }); - - if (!response.ok) { - console.error('Failed to send feedback:', response.status, response.statusText); - const errorText = await response.text(); - console.error('Error response:', errorText); - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - console.log('Feedback sent successfully:', result); - } catch (error) { - console.error('Failed to send feedback:', error); - // Could enhance this with toast notifications in the future - } - }; - - // Effect to handle retry after history state update - useEffect(() => { - if (retryQuery) { - runQuery(retryQuery); - setRetryQuery(null); - } - }, [retryQuery, history]); - - // Effect to handle initial query from URL parameter - useEffect(() => { - if (initialQuery && !loading && !currentQuestion) { - setShowAutofillPill(true); - runQuery(initialQuery); - } - }, [initialQuery]); - - // handy “reset” helper - const reset = () => { - setQuery(''); - setAnswerMd(null); - setCurrentQuestion(null); - inputRef.current?.focus(); - }; - - const runQuery = async (overrideQuery?: string) => { - const q = overrideQuery || query.trim(); - if (!q || loading) return; - setCurrentQuestion(q); - if (!overrideQuery) { - setQuery(''); - inputRef.current?.blur(); - setFocused(false); - } - setLoading(true); - setAnswerMd(null); - - if (USE_DUMMY_API) { - await new Promise((r) => setTimeout(r, 1000)); - if (FORCE_ERROR_STATE) { - const errorId = Date.now().toString(); - setHistory(prev => [{ - id: errorId, - question: q, - answer: '**Error** – please try again.', - isError: true - }, ...prev]); - - } else { - const responseId = Date.now().toString(); - setHistory(prev => [{ - id: responseId, - question: q, - answer: DUMMY_MARKDOWN - }, ...prev]); - - } - setLoading(false); - setCurrentQuestion(null); - return; - } - - try { - const res = await fetch(`${API_URL}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: q, - sdks: selectedSdk ? [selectedSdk] : undefined, - email: userEmail || undefined - }), - }); - if (!res.ok) throw new Error('Bad response'); - const text = await res.text(); - const responseId = Date.now().toString(); - setHistory(prev => [{ - id: responseId, - question: q, - answer: text - }, ...prev]); - - } catch (err) { - const errorId = Date.now().toString(); - setHistory(prev => [{ - id: errorId, - question: q, - answer: '**Error** – please try again.', - isError: true - }, ...prev]); - console.error(err); - - } finally { - setLoading(false); - setCurrentQuestion(null); - setShowAutofillPill(false); - } - }; - - // ⏎ to run - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') { - e.preventDefault(); - runQuery(); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }); - - return ( -
- {/* Search input with SDK selector */} -
- {/* SDK selector */} -
- - - {showSdkDropdown && ( -
- {SDK_OPTIONS.map((option) => ( - - ))} -
- )} -
- - {/* Search input */} -
- setFocused(true)} - onBlur={() => setFocused(false)} - ref={inputRef} - value={query} - onChange={(e) => setQuery(e.target.value)} - placeholder={answerMd ? 'Ask another question…' : 'Ask anything about Superwall...'} - className={cn( - 'w-full rounded-[var(--radius-lg)] border bg-transparent px-3 pl-8 pr-8 h-[44px]', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-primary', - loading && 'opacity-50 cursor-not-allowed' - )} - style={{ borderRadius: 'var(--radius-lg)' }} - disabled={loading} - /> - - {focused && ( - - )} -
-
- - {currentQuestion && ( -
-
-
- {currentQuestion} - {showAutofillPill && ( - - AUTOFILLED - - )} -
- {!loading && ( - - )} -
-
-
- {loading ? ( -

- Loading… -

- ) : ( - {history[0].answer} - )} -
-
- )} - - {history.map((item, idx) => ( -
-
- {item.question} - -
-
-
- {item.isError ? ( -
-
- {item.answer} -
- -
- ) : ( -
- {/* Content with feedback buttons pinned to bottom right */} -
-
- {item.answer} -
- - {/* Feedback UI - pinned to bottom right corner */} -
- - -
-
- - {/* Feedback comment input */} - {feedbackState[item.id]?.showInput && ( -
-

- Optional feedback (helps improve AI responses) -

-
-