Skip to content

Commit 4039050

Browse files
committed
feat: rate limits
1 parent 49f9115 commit 4039050

File tree

6 files changed

+353
-12
lines changed

6 files changed

+353
-12
lines changed

examples/react-chatbot/app/page.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
22
import { StreamChat } from 'stream-chat';
33
import { AIChatApp } from '@/components/AIChatApp';
44
import { ThemeProvider } from '@/components/ThemeContext';
5+
import { UserProvider } from '@/components/UserProvider';
56

67
import '../components/index.scss';
78

@@ -14,14 +15,18 @@ const generateUserToken = (userId: string) => {
1415

1516
const client = new StreamChat(apiKey, secret);
1617
const token = client.createToken(userId);
17-
return { apiKey, token, userId };
18+
return { apiKey, token };
1819
};
1920

2021
export default async function Home(props: {
2122
searchParams: Promise<{ conversation_id?: string; user_id?: string }>;
2223
}) {
23-
const { conversation_id, user_id = 'jane' } = await props.searchParams;
24-
const { apiKey, token, userId } = generateUserToken(user_id);
24+
const { conversation_id, user_id } = await props.searchParams;
25+
26+
// If no user_id provided, generate a random UUID
27+
// The client will persist this in localStorage
28+
const userId = user_id || crypto.randomUUID();
29+
const { apiKey, token } = generateUserToken(userId);
2530

2631
const filters: ChannelFilters = {
2732
members: { $in: [userId] },
@@ -37,15 +42,17 @@ export default async function Home(props: {
3742

3843
return (
3944
<ThemeProvider>
40-
<AIChatApp
41-
apiKey={apiKey}
42-
userToken={token}
43-
userId={userId}
44-
filters={filters}
45-
options={options}
46-
sort={sort}
47-
initialChannelId={conversation_id}
48-
/>
45+
<UserProvider>
46+
<AIChatApp
47+
apiKey={apiKey}
48+
userToken={token}
49+
userId={userId}
50+
filters={filters}
51+
options={options}
52+
sort={sort}
53+
initialChannelId={conversation_id}
54+
/>
55+
</UserProvider>
4956
</ThemeProvider>
5057
);
5158
}

examples/react-chatbot/components/MessageInputBar/MessageInputBar.scss

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,43 @@
11
.ai-demo-message-input-bar {
22
display: flex;
3+
flex-direction: column;
4+
align-items: center;
35
justify-content: center;
46
padding: 1.5rem;
57
background-color: var(--ai-demo-bg-primary);
68
color: var(--ai-demo-text-primary);
79
border-top: 1px solid var(--ai-demo-border);
810

11+
.ai-demo-rate-limit-message {
12+
width: 100%;
13+
max-width: 900px;
14+
padding: 1rem 1.25rem;
15+
margin-bottom: 1rem;
16+
background-color: rgba(255, 193, 7, 0.1);
17+
border: 1px solid rgba(255, 193, 7, 0.3);
18+
border-radius: 12px;
19+
color: var(--ai-demo-text-primary);
20+
font-size: 0.9375rem;
21+
line-height: 1.5;
22+
23+
display: flex;
24+
gap: 8px;
25+
}
26+
27+
[data-theme='dark'] & .rate-limit-message {
28+
background-color: rgba(255, 193, 7, 0.15);
29+
}
30+
31+
/* Disabled state styles */
32+
fieldset:disabled {
33+
opacity: 0.5;
34+
cursor: not-allowed;
35+
36+
* {
37+
pointer-events: none;
38+
}
39+
}
40+
941
/* Override AIMessageComposer styles */
1042
.aicr__ai-message-composer__form {
1143
width: 100%;

examples/react-chatbot/components/MessageInputBar/MessageInputBar.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
useMessageComposer,
1717
} from 'stream-chat-react';
1818
import { startAiAgent } from '@/components/api';
19+
import {
20+
checkRateLimit,
21+
recordMessage,
22+
formatTimeRemaining,
23+
} from '@/components/rateLimitUtils';
1924
import './MessageInputBar.scss';
2025

2126
const isWatchedByAI = (channel: Channel) => {
@@ -32,6 +37,30 @@ export const MessageInputBar = () => {
3237

3338
const { attachments } = useAttachmentsForPreview();
3439
const [selectedModel, setSelectedModel] = useState<string>();
40+
const [rateLimitState, setRateLimitState] = useState<{
41+
isLimited: boolean;
42+
resetTime: number | null;
43+
remainingMessages: number;
44+
}>({
45+
isLimited: false,
46+
resetTime: null,
47+
remainingMessages: 10,
48+
});
49+
50+
// Check rate limit when channel changes or on mount
51+
useEffect(() => {
52+
if (!channel?.id) return;
53+
54+
const updateRateLimit = () => {
55+
const state = checkRateLimit(channel.id!);
56+
setRateLimitState(state);
57+
};
58+
59+
updateRateLimit();
60+
61+
const interval = setInterval(updateRateLimit, 60000);
62+
return () => clearInterval(interval);
63+
}, [channel?.id]);
3564

3665
useEffect(() => {
3766
if (!composer) return;
@@ -51,7 +80,17 @@ export const MessageInputBar = () => {
5180

5281
return (
5382
<div className="ai-demo-message-input-bar">
83+
{rateLimitState.isLimited && rateLimitState.resetTime && (
84+
<div className="ai-demo-rate-limit-message">
85+
<span className="material-symbols-rounded">info</span>
86+
<span>
87+
Limit reached, 10 messages per conversation. Resets in{' '}
88+
<strong>{formatTimeRemaining(rateLimitState.resetTime)}</strong>.
89+
</span>
90+
</div>
91+
)}
5492
<AIMessageComposer
93+
disabled={rateLimitState.isLimited}
5594
onChange={(e) => {
5695
const input = e.currentTarget.elements.namedItem(
5796
'attachments',
@@ -67,6 +106,9 @@ export const MessageInputBar = () => {
67106
const event = e;
68107
event.preventDefault();
69108

109+
// Check rate limit before processing
110+
if (rateLimitState.isLimited) return;
111+
70112
const target = event.currentTarget;
71113

72114
const formData = new FormData(target);
@@ -95,6 +137,13 @@ export const MessageInputBar = () => {
95137
}
96138

97139
await sendMessage(composedData);
140+
141+
// Record message after successful send
142+
recordMessage(channel.id!);
143+
144+
// Update rate limit state
145+
const newState = checkRateLimit(channel.id!);
146+
setRateLimitState(newState);
98147
}}
99148
>
100149
<AIMessageComposer.AttachmentPreview>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { useRouter, useSearchParams } from 'next/navigation';
5+
import { getUserId } from './rateLimitUtils';
6+
7+
/**
8+
* Client component that handles user initialization and URL synchronization
9+
*/
10+
export const UserProvider = ({ children }: { children: React.ReactNode }) => {
11+
const [isInitialized, setIsInitialized] = useState(false);
12+
const router = useRouter();
13+
const searchParams = useSearchParams();
14+
15+
useEffect(() => {
16+
const urlUserId = searchParams.get('user_id');
17+
const currentUserId = getUserId();
18+
19+
// If no user_id in URL, add it
20+
if (!urlUserId) {
21+
const params = new URLSearchParams(searchParams.toString());
22+
params.set('user_id', currentUserId);
23+
router.replace(`?${params.toString()}`);
24+
}
25+
26+
setIsInitialized(true);
27+
}, [router, searchParams]);
28+
29+
if (!isInitialized) {
30+
return <>Loading...</>;
31+
}
32+
33+
return <>{children}</>;
34+
};
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Rate limiting utilities for managing user message limits
3+
* Limits: 10 messages per conversation in a 4-hour interval
4+
*/
5+
6+
const RATE_LIMIT_KEY_PREFIX = 'rate_limit_';
7+
const USER_ID_KEY = 'user_id';
8+
const MESSAGE_LIMIT = 10;
9+
const TIME_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours in milliseconds
10+
11+
interface RateLimitData {
12+
messageCount: number;
13+
firstMessageTimestamp: number;
14+
conversationId: string;
15+
}
16+
17+
/**
18+
* Get or create user ID
19+
* Checks for user_id in URL params first, then localStorage
20+
* If neither exists, generates a new UUID
21+
*/
22+
export const getUserId = (): string => {
23+
// Check URL params first
24+
if (typeof window !== 'undefined') {
25+
const params = new URLSearchParams(window.location.search);
26+
const urlUserId = params.get('user_id');
27+
if (urlUserId) {
28+
// Store in localStorage for persistence
29+
localStorage.setItem(USER_ID_KEY, urlUserId);
30+
return urlUserId;
31+
}
32+
33+
// Check localStorage
34+
const storedUserId = localStorage.getItem(USER_ID_KEY);
35+
if (storedUserId) {
36+
return storedUserId;
37+
}
38+
39+
// Generate new UUID
40+
const newUserId = crypto.randomUUID();
41+
localStorage.setItem(USER_ID_KEY, newUserId);
42+
return newUserId;
43+
}
44+
45+
// Fallback for server-side rendering
46+
return crypto.randomUUID();
47+
};
48+
49+
/**
50+
* Get rate limit data for a conversation
51+
*/
52+
export const getRateLimitData = (
53+
conversationId: string,
54+
): RateLimitData | null => {
55+
if (typeof window === 'undefined') return null;
56+
57+
const key = `${RATE_LIMIT_KEY_PREFIX}${conversationId}`;
58+
const stored = localStorage.getItem(key);
59+
60+
if (!stored) return null;
61+
62+
try {
63+
return JSON.parse(stored) as RateLimitData;
64+
} catch {
65+
return null;
66+
}
67+
};
68+
69+
/**
70+
* Save rate limit data for a conversation
71+
*/
72+
const saveRateLimitData = (data: RateLimitData): void => {
73+
if (typeof window === 'undefined') return;
74+
75+
const key = `${RATE_LIMIT_KEY_PREFIX}${data.conversationId}`;
76+
localStorage.setItem(key, JSON.stringify(data));
77+
};
78+
79+
/**
80+
* Check if rate limit has been exceeded
81+
* Returns an object with isLimited flag and resetTime if limited
82+
*/
83+
export const checkRateLimit = (
84+
conversationId: string,
85+
): {
86+
isLimited: boolean;
87+
resetTime: number | null;
88+
remainingMessages: number;
89+
} => {
90+
const data = getRateLimitData(conversationId);
91+
92+
if (!data) {
93+
return {
94+
isLimited: false,
95+
resetTime: null,
96+
remainingMessages: MESSAGE_LIMIT,
97+
};
98+
}
99+
100+
const now = Date.now();
101+
const timeElapsed = now - data.firstMessageTimestamp;
102+
103+
// If time window has passed, reset the limit
104+
if (timeElapsed >= TIME_WINDOW_MS) {
105+
return {
106+
isLimited: false,
107+
resetTime: null,
108+
remainingMessages: MESSAGE_LIMIT,
109+
};
110+
}
111+
112+
// Check if limit exceeded
113+
const isLimited = data.messageCount >= MESSAGE_LIMIT;
114+
const resetTime = isLimited
115+
? data.firstMessageTimestamp + TIME_WINDOW_MS
116+
: null;
117+
const remainingMessages = Math.max(0, MESSAGE_LIMIT - data.messageCount);
118+
119+
return {
120+
isLimited,
121+
resetTime,
122+
remainingMessages,
123+
};
124+
};
125+
126+
/**
127+
* Record a new message sent in the conversation
128+
*/
129+
export const recordMessage = (conversationId: string): void => {
130+
const data = getRateLimitData(conversationId);
131+
const now = Date.now();
132+
133+
if (!data) {
134+
// First message in this conversation
135+
saveRateLimitData({
136+
messageCount: 1,
137+
firstMessageTimestamp: now,
138+
conversationId,
139+
});
140+
return;
141+
}
142+
143+
const timeElapsed = now - data.firstMessageTimestamp;
144+
145+
if (timeElapsed >= TIME_WINDOW_MS) {
146+
// Time window has passed, reset the counter
147+
saveRateLimitData({
148+
messageCount: 1,
149+
firstMessageTimestamp: now,
150+
conversationId,
151+
});
152+
} else {
153+
// Increment the counter
154+
saveRateLimitData({
155+
...data,
156+
messageCount: data.messageCount + 1,
157+
});
158+
}
159+
};
160+
161+
/**
162+
* Format time remaining until reset
163+
*/
164+
export const formatTimeRemaining = (resetTime: number): string => {
165+
const now = Date.now();
166+
const msRemaining = resetTime - now;
167+
168+
if (msRemaining <= 0) return '0m';
169+
170+
const hours = Math.floor(msRemaining / (60 * 60 * 1000));
171+
const minutes = Math.floor((msRemaining % (60 * 60 * 1000)) / (60 * 1000));
172+
173+
if (hours > 0) {
174+
return `${hours}h ${minutes}m`;
175+
}
176+
177+
return `${minutes}m`;
178+
};

0 commit comments

Comments
 (0)