Skip to content

Commit 8801867

Browse files
davidmyttonCopilot
andauthored
feat: add chat UI (#158)
Co-authored-by: Copilot <copilot@github.com>
1 parent ce25fb5 commit 8801867

File tree

8 files changed

+471
-2
lines changed

8 files changed

+471
-2
lines changed

examples/nextjs/.env.local.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
# Get your Arcjet key from https://app.arcjet.com
2-
ARCJET_KEY=
2+
ARCJET_KEY=
3+
# Optional for testing the chat route
4+
OPENAI_API_KEY=

examples/nextjs/app/chat/page.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"use client";
2+
3+
import { useChat } from "@ai-sdk/react";
4+
import { DefaultChatTransport } from "ai";
5+
import { useState } from "react";
6+
7+
export default function Chat() {
8+
const [input, setInput] = useState("");
9+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
10+
11+
const { messages, sendMessage, status } = useChat({
12+
transport: new DefaultChatTransport({ api: "/chat/test" }),
13+
onError: async (e) => {
14+
setErrorMessage(e.message);
15+
},
16+
});
17+
18+
return (
19+
<main className="page">
20+
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
21+
<div className="section">
22+
<h1 className="heading-primary">AI chat</h1>
23+
<p className="typography-primary">
24+
This chat is protected by Arcjet: bot detection blocks automated
25+
clients, a token bucket rate limits AI usage, sensitive information
26+
detection prevents data leaks, and prompt injection detection stops
27+
manipulation attempts.
28+
</p>
29+
</div>
30+
31+
<hr className="divider" />
32+
33+
<div className="section">
34+
<h2 className="heading-secondary">Try it</h2>
35+
36+
{messages.length > 0 && (
37+
<div
38+
style={{
39+
display: "flex",
40+
flexDirection: "column",
41+
gap: "0.75rem",
42+
width: "100%",
43+
}}
44+
>
45+
{messages.map((message) => (
46+
<div
47+
key={message.id}
48+
style={{
49+
display: "flex",
50+
flexDirection: "column",
51+
gap: "0.25rem",
52+
alignSelf:
53+
message.role === "user" ? "flex-end" : "flex-start",
54+
maxWidth: "80%",
55+
}}
56+
>
57+
<span
58+
style={{
59+
fontSize: "0.75rem",
60+
fontWeight: 600,
61+
color: "var(--theme-text-muted)",
62+
textTransform: "uppercase",
63+
letterSpacing: "0.05em",
64+
alignSelf:
65+
message.role === "user" ? "flex-end" : "flex-start",
66+
}}
67+
>
68+
{message.role === "user" ? "You" : "AI"}
69+
</span>
70+
<div
71+
style={{
72+
padding: "0.75rem 1rem",
73+
borderRadius: "0.5rem",
74+
border: "1px solid var(--theme-border-level1)",
75+
backgroundColor:
76+
message.role === "user"
77+
? "var(--theme-foreground)"
78+
: "var(--theme-surface)",
79+
color:
80+
message.role === "user"
81+
? "var(--theme-background)"
82+
: "var(--theme-text-primary)",
83+
fontSize: "0.9375rem",
84+
lineHeight: "1.5rem",
85+
whiteSpace: "pre-wrap",
86+
}}
87+
>
88+
{message.parts.map((part, i) => {
89+
switch (part.type) {
90+
case "text":
91+
return (
92+
<span key={`${message.id}-${i}`}>{part.text}</span>
93+
);
94+
}
95+
})}
96+
</div>
97+
</div>
98+
))}
99+
</div>
100+
)}
101+
102+
{status === "submitted" && (
103+
<div
104+
style={{
105+
display: "flex",
106+
alignItems: "center",
107+
gap: "0.5rem",
108+
fontSize: "0.875rem",
109+
color: "var(--theme-text-muted)",
110+
}}
111+
>
112+
<span
113+
style={{
114+
display: "inline-block",
115+
width: "1rem",
116+
height: "1rem",
117+
border: "2px solid var(--theme-border-level1)",
118+
borderTopColor: "var(--theme-text-muted)",
119+
borderRadius: "50%",
120+
animation: "spin 0.8s linear infinite",
121+
}}
122+
/>
123+
AI is thinking&hellip;
124+
</div>
125+
)}
126+
127+
{errorMessage && (
128+
<div
129+
style={{
130+
fontSize: "0.875rem",
131+
fontWeight: 600,
132+
lineHeight: "1.25rem",
133+
padding: "0.75rem",
134+
borderRadius: "0.5rem",
135+
border: "1px solid #ef4444",
136+
backgroundColor: "#2d0a0a",
137+
color: "#fca5a5",
138+
}}
139+
>
140+
{errorMessage}
141+
</div>
142+
)}
143+
144+
<form
145+
onSubmit={(e) => {
146+
e.preventDefault();
147+
setErrorMessage(null);
148+
sendMessage({ text: input });
149+
setInput("");
150+
}}
151+
className="form form--wide"
152+
>
153+
<div className="form-field">
154+
<label className="form-label">
155+
Message
156+
<input
157+
className="form-input"
158+
value={input}
159+
placeholder="Say something..."
160+
onChange={(e) => setInput(e.currentTarget.value)}
161+
/>
162+
</label>
163+
</div>
164+
<button type="submit" className="button-primary form-button">
165+
Send
166+
</button>
167+
</form>
168+
</div>
169+
</main>
170+
);
171+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { UIMessage } from "ai";
2+
import { convertToModelMessages, isTextUIPart, streamText } from "ai";
3+
import { openai } from "@ai-sdk/openai";
4+
import arcjet, {
5+
detectBot,
6+
detectPromptInjection,
7+
sensitiveInfo,
8+
shield,
9+
tokenBucket,
10+
} from "@/lib/arcjet";
11+
12+
// Opt out of caching
13+
export const dynamic = "force-dynamic";
14+
15+
const aj = arcjet
16+
// Shield protects against common web attacks e.g. SQL injection
17+
.withRule(shield({ mode: "LIVE" }))
18+
// Block all automated clients — bots inflate AI costs
19+
.withRule(
20+
detectBot({
21+
mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only
22+
allow: [], // Block all bots. See https://arcjet.com/bot-list
23+
}),
24+
)
25+
// Enforce budgets to control AI costs. Adjust rates and limits as needed.
26+
.withRule(
27+
tokenBucket({
28+
// Track budgets per user — replace "userId" with any stable identifier
29+
characteristics: ["userId"],
30+
mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only
31+
refillRate: 2_000, // Refill 2,000 tokens per hour
32+
interval: "1m",
33+
capacity: 5_000, // Maximum 5,000 tokens in the bucket
34+
}),
35+
)
36+
// Block messages containing sensitive information to prevent data leaks
37+
.withRule(
38+
sensitiveInfo({
39+
mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only
40+
// Block PII types that should never appear in AI prompts.
41+
// Remove types your app legitimately handles (e.g. EMAIL for a support bot).
42+
deny: ["CREDIT_CARD_NUMBER", "EMAIL"],
43+
}),
44+
)
45+
// Detect prompt injection attacks before they reach your AI model
46+
.withRule(
47+
detectPromptInjection({
48+
mode: "LIVE", // Blocks requests. Use "DRY_RUN" to log only
49+
}),
50+
);
51+
52+
export async function POST(req: Request) {
53+
// Replace with your session/auth lookup to get a stable user ID
54+
const userId = "user-123";
55+
const { messages }: { messages: UIMessage[] } = await req.json();
56+
const modelMessages = await convertToModelMessages(messages);
57+
58+
// Estimate token cost: ~1 token per 4 characters of text (rough heuristic).
59+
// For accurate counts use https://www.npmjs.com/package/tiktoken
60+
const totalChars = modelMessages.reduce((sum, m) => {
61+
const content =
62+
typeof m.content === "string" ? m.content : JSON.stringify(m.content);
63+
return sum + content.length;
64+
}, 0);
65+
const estimate = Math.ceil(totalChars / 4);
66+
67+
// Check the most recent user message for sensitive information and prompt injection.
68+
// Pass the full conversation if you want to scan all messages.
69+
const lastMessage: string = (messages.at(-1)?.parts ?? [])
70+
.filter(isTextUIPart)
71+
.map((p) => p.text)
72+
.join(" ");
73+
74+
// Check with Arcjet before calling the AI provider
75+
const decision = await aj.protect(req, {
76+
userId,
77+
requested: estimate,
78+
sensitiveInfoValue: lastMessage,
79+
detectPromptInjectionMessage: lastMessage,
80+
});
81+
82+
if (decision.isDenied()) {
83+
if (decision.reason.isBot()) {
84+
return new Response("Automated clients are not permitted", {
85+
status: 403,
86+
});
87+
} else if (decision.reason.isRateLimit()) {
88+
return new Response("AI usage limit exceeded", { status: 429 });
89+
} else if (decision.reason.isSensitiveInfo()) {
90+
return new Response("Sensitive information detected", { status: 400 });
91+
} else if (decision.reason.isPromptInjection()) {
92+
return new Response(
93+
"Prompt injection detected — please rephrase your message",
94+
{ status: 400 },
95+
);
96+
} else {
97+
return new Response("Forbidden", { status: 403 });
98+
}
99+
}
100+
101+
const result = await streamText({
102+
model: openai("gpt-4o"),
103+
messages: modelMessages,
104+
});
105+
106+
return result.toUIMessageStreamResponse();
107+
}

examples/nextjs/environment.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ declare namespace NodeJS {
33
readonly ARCJET_KEY: string;
44
readonly AUTH_SECRET: string;
55
readonly ARCJET_ENV?: string;
6+
readonly OPENAI_API_KEY?: string;
67
}
78
}

examples/nextjs/lib/arcjet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import arcjet, {
66
sensitiveInfo,
77
shield,
88
slidingWindow,
9+
tokenBucket,
910
} from "@arcjet/next";
1011

1112
// Re-export the rules to simplify imports inside handlers
@@ -17,6 +18,7 @@ export {
1718
sensitiveInfo,
1819
shield,
1920
slidingWindow,
21+
tokenBucket,
2022
};
2123

2224
// Create a base Arcjet instance for use by each handler

examples/nextjs/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0 commit comments

Comments
 (0)