From d2ccdbdb8b2d143bdfff35d1d81717c80cf7af14 Mon Sep 17 00:00:00 2001 From: David Mytton Date: Wed, 8 Apr 2026 11:18:29 +0000 Subject: [PATCH] feat: add chat UI Co-authored-by: Copilot --- examples/nextjs/.env.local.example | 4 +- examples/nextjs/app/chat/page.tsx | 171 +++++++++++++++++++++++ examples/nextjs/app/chat/test/route.ts | 107 +++++++++++++++ examples/nextjs/environment.d.ts | 1 + examples/nextjs/lib/arcjet.ts | 2 + examples/nextjs/next-env.d.ts | 2 +- examples/nextjs/package-lock.json | 183 +++++++++++++++++++++++++ examples/nextjs/package.json | 3 + 8 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 examples/nextjs/app/chat/page.tsx create mode 100644 examples/nextjs/app/chat/test/route.ts diff --git a/examples/nextjs/.env.local.example b/examples/nextjs/.env.local.example index b74cc4a..18a5942 100644 --- a/examples/nextjs/.env.local.example +++ b/examples/nextjs/.env.local.example @@ -1,2 +1,4 @@ # Get your Arcjet key from https://app.arcjet.com -ARCJET_KEY= \ No newline at end of file +ARCJET_KEY= +# Optional for testing the chat route +OPENAI_API_KEY= \ No newline at end of file diff --git a/examples/nextjs/app/chat/page.tsx b/examples/nextjs/app/chat/page.tsx new file mode 100644 index 0000000..235dc94 --- /dev/null +++ b/examples/nextjs/app/chat/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useState } from "react"; + +export default function Chat() { + const [input, setInput] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ api: "/chat/test" }), + onError: async (e) => { + setErrorMessage(e.message); + }, + }); + + return ( +
+ +
+

AI chat

+

+ This chat is protected by Arcjet: bot detection blocks automated + clients, a token bucket rate limits AI usage, sensitive information + detection prevents data leaks, and prompt injection detection stops + manipulation attempts. +

+
+ +
+ +
+

Try it

+ + {messages.length > 0 && ( +
+ {messages.map((message) => ( +
+ + {message.role === "user" ? "You" : "AI"} + +
+ {message.parts.map((part, i) => { + switch (part.type) { + case "text": + return ( + {part.text} + ); + } + })} +
+
+ ))} +
+ )} + + {status === "submitted" && ( +
+ + AI is thinking… +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
{ + e.preventDefault(); + setErrorMessage(null); + sendMessage({ text: input }); + setInput(""); + }} + className="form form--wide" + > +
+ +
+ +
+
+
+ ); +} diff --git a/examples/nextjs/app/chat/test/route.ts b/examples/nextjs/app/chat/test/route.ts new file mode 100644 index 0000000..95e5c74 --- /dev/null +++ b/examples/nextjs/app/chat/test/route.ts @@ -0,0 +1,107 @@ +import type { UIMessage } from "ai"; +import { convertToModelMessages, isTextUIPart, streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import arcjet, { + detectBot, + detectPromptInjection, + sensitiveInfo, + shield, + tokenBucket, +} from "@/lib/arcjet"; + +// Opt out of caching +export const dynamic = "force-dynamic"; + +const aj = arcjet + // Shield protects against common web attacks e.g. SQL injection + .withRule(shield({ mode: "LIVE" })) + // Block all automated clients — bots inflate AI costs + .withRule( + detectBot({ + mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only + allow: [], // Block all bots. See https://arcjet.com/bot-list + }), + ) + // Enforce budgets to control AI costs. Adjust rates and limits as needed. + .withRule( + tokenBucket({ + // Track budgets per user — replace "userId" with any stable identifier + characteristics: ["userId"], + mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only + refillRate: 2_000, // Refill 2,000 tokens per hour + interval: "1m", + capacity: 5_000, // Maximum 5,000 tokens in the bucket + }), + ) + // Block messages containing sensitive information to prevent data leaks + .withRule( + sensitiveInfo({ + mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only + // Block PII types that should never appear in AI prompts. + // Remove types your app legitimately handles (e.g. EMAIL for a support bot). + deny: ["CREDIT_CARD_NUMBER", "EMAIL"], + }), + ) + // Detect prompt injection attacks before they reach your AI model + .withRule( + detectPromptInjection({ + mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only + }), + ); + +export async function POST(req: Request) { + // Replace with your session/auth lookup to get a stable user ID + const userId = "user-123"; + const { messages }: { messages: UIMessage[] } = await req.json(); + const modelMessages = await convertToModelMessages(messages); + + // Estimate token cost: ~1 token per 4 characters of text (rough heuristic). + // For accurate counts use https://www.npmjs.com/package/tiktoken + const totalChars = modelMessages.reduce((sum, m) => { + const content = + typeof m.content === "string" ? m.content : JSON.stringify(m.content); + return sum + content.length; + }, 0); + const estimate = Math.ceil(totalChars / 4); + + // Check the most recent user message for sensitive information and prompt injection. + // Pass the full conversation if you want to scan all messages. + const lastMessage: string = (messages.at(-1)?.parts ?? []) + .filter(isTextUIPart) + .map((p) => p.text) + .join(" "); + + // Check with Arcjet before calling the AI provider + const decision = await aj.protect(req, { + userId, + requested: estimate, + sensitiveInfoValue: lastMessage, + detectPromptInjectionMessage: lastMessage, + }); + + if (decision.isDenied()) { + if (decision.reason.isBot()) { + return new Response("Automated clients are not permitted", { + status: 403, + }); + } else if (decision.reason.isRateLimit()) { + return new Response("AI usage limit exceeded", { status: 429 }); + } else if (decision.reason.isSensitiveInfo()) { + return new Response("Sensitive information detected", { status: 400 }); + } else if (decision.reason.isPromptInjection()) { + return new Response( + "Prompt injection detected — please rephrase your message", + { status: 400 }, + ); + } else { + return new Response("Forbidden", { status: 403 }); + } + } + + const result = await streamText({ + model: openai("gpt-4o"), + messages: modelMessages, + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/examples/nextjs/environment.d.ts b/examples/nextjs/environment.d.ts index bc4b9c9..a615aa0 100644 --- a/examples/nextjs/environment.d.ts +++ b/examples/nextjs/environment.d.ts @@ -3,5 +3,6 @@ declare namespace NodeJS { readonly ARCJET_KEY: string; readonly AUTH_SECRET: string; readonly ARCJET_ENV?: string; + readonly OPENAI_API_KEY?: string; } } diff --git a/examples/nextjs/lib/arcjet.ts b/examples/nextjs/lib/arcjet.ts index 5b8655f..1b2b657 100644 --- a/examples/nextjs/lib/arcjet.ts +++ b/examples/nextjs/lib/arcjet.ts @@ -6,6 +6,7 @@ import arcjet, { sensitiveInfo, shield, slidingWindow, + tokenBucket, } from "@arcjet/next"; // Re-export the rules to simplify imports inside handlers @@ -17,6 +18,7 @@ export { sensitiveInfo, shield, slidingWindow, + tokenBucket, }; // Create a base Arcjet instance for use by each handler diff --git a/examples/nextjs/next-env.d.ts b/examples/nextjs/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/examples/nextjs/next-env.d.ts +++ b/examples/nextjs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/nextjs/package-lock.json b/examples/nextjs/package-lock.json index d5f9206..3475b09 100644 --- a/examples/nextjs/package-lock.json +++ b/examples/nextjs/package-lock.json @@ -7,6 +7,8 @@ "name": "@arcjet-examples/nextjs", "license": "Apache-2.0", "dependencies": { + "@ai-sdk/openai": "3.0.52", + "@ai-sdk/react": "3.0.155", "@arcjet/decorate": "1.3.1", "@arcjet/env": "1.3.1", "@arcjet/ip": "1.3.1", @@ -15,6 +17,7 @@ "@fontsource/ibm-plex-mono": "5.2.7", "@hookform/resolvers": "5.2.2", "@nosecone/next": "1.3.0", + "ai": "6.0.153", "next": "16.2.1", "next-themes": "0.4.6", "react": "19.2.4", @@ -33,6 +36,86 @@ "node": ">=20" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.93", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.93.tgz", + "integrity": "sha512-8D6C9eEvDq6IgrdlWzpbniahDkoLiieTCrpzH8p/Hw63/0iPnZJ1uZcqxHrDIVDW/+aaGhBXqmx5C7HSd2eMmQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.52", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.52.tgz", + "integrity": "sha512-4Rr8NCGmfWTz6DCUvixn9UmyZcMatiHn0zWoMzI3JCUe9R1P/vsPOpCBALKoSzVYOjyJnhtnVIbfUKujcS39uw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", + "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.155", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.155.tgz", + "integrity": "sha512-cvDfJrfvpdTxacK8EHpeficVpxxmZV7mBVqP4oWAFU2QWSNdurmrtnd30OCCyynv11tfCFNfxsnyFtr60rJAQw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.23", + "ai": "6.0.153", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, "node_modules/@arcjet/analyze": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@arcjet/analyze/-/analyze-1.3.1.tgz", @@ -906,6 +989,15 @@ "next": ">=14" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -922,6 +1014,12 @@ "node": ">=18" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -967,6 +1065,33 @@ "@types/react": "^19.2.0" } }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ai": { + "version": "6.0.153", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.153.tgz", + "integrity": "sha512-UlgBe4k0Ja1m1Eufn6FVSsHoF0sc7qwxX35ywJPDogIvBz0pHc+NOmCqiRY904DczNYIuwpZfKBLVz8HXgu3mg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.93", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/arcjet": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/arcjet/-/arcjet-1.3.1.tgz", @@ -1030,6 +1155,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1040,6 +1174,15 @@ "node": ">=8" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1054,6 +1197,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/next": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", @@ -1343,6 +1492,31 @@ } } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1379,6 +1553,15 @@ "dev": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 0e37421..f567fd9 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -27,6 +27,8 @@ "test:run": "playwright test" }, "dependencies": { + "@ai-sdk/openai": "3.0.52", + "@ai-sdk/react": "3.0.155", "@arcjet/decorate": "1.3.1", "@arcjet/env": "1.3.1", "@arcjet/ip": "1.3.1", @@ -35,6 +37,7 @@ "@fontsource/ibm-plex-mono": "5.2.7", "@hookform/resolvers": "5.2.2", "@nosecone/next": "1.3.0", + "ai": "6.0.153", "next": "16.2.1", "next-themes": "0.4.6", "react": "19.2.4",