From 9dc2b4f2168156a8d1267af2357d37b1b54ce3ba Mon Sep 17 00:00:00 2001 From: colinislit Date: Mon, 1 Dec 2025 18:00:51 +0100 Subject: [PATCH] feat: add rate limiting with countdown timer for docs chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add in-memory rate limiter (10 requests/minute per user) - Show informative message when limit reached explaining demo context - Display countdown timer showing when chat becomes available again - Auto-reset rate limit when timer expires - Update documentation with rate limit FAQ and category selection flow šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/api/docs/chat/route.ts | 54 +++++++++++++ components/docs-chat/docs-chat-widget.tsx | 17 +++- components/docs-chat/index.ts | 1 + components/docs-chat/rate-limit-message.tsx | 78 +++++++++++++++++++ components/docs-chat/use-docs-chat.ts | 26 +++++++ .../ai-documentatie-assistent.mdx | 34 ++++++-- 6 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 components/docs-chat/rate-limit-message.tsx diff --git a/app/api/docs/chat/route.ts b/app/api/docs/chat/route.ts index 48ebef3..a2072c6 100644 --- a/app/api/docs/chat/route.ts +++ b/app/api/docs/chat/route.ts @@ -10,6 +10,40 @@ const DOCS_ASSISTANT_MODEL = process.env.DOCS_ASSISTANT_MODEL ?? 'claude-sonnet- const MAX_HISTORY_MESSAGES = 10 const MAX_USER_MESSAGE_LENGTH = 2000 +// Rate limiting configuration +const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 10 // max requests per window + +// In-memory rate limit store (resets on server restart) +const rateLimitStore = new Map() + +function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetIn: number } { + const now = Date.now() + const record = rateLimitStore.get(userId) + + // Clean up expired entries periodically + if (rateLimitStore.size > 1000) { + for (const [key, value] of rateLimitStore.entries()) { + if (value.resetTime < now) { + rateLimitStore.delete(key) + } + } + } + + if (!record || record.resetTime < now) { + // New window + rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS }) + return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1, resetIn: RATE_LIMIT_WINDOW_MS } + } + + if (record.count >= RATE_LIMIT_MAX_REQUESTS) { + return { allowed: false, remaining: 0, resetIn: record.resetTime - now } + } + + record.count++ + return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - record.count, resetIn: record.resetTime - now } +} + const ChatMessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(4000), @@ -29,6 +63,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Niet geauthenticeerd' }, { status: 401 }) } + // Check rate limit + const rateLimit = checkRateLimit(session.user.id) + if (!rateLimit.allowed) { + const resetInSeconds = Math.ceil(rateLimit.resetIn / 1000) + return NextResponse.json( + { + error: `Te veel verzoeken. Probeer het over ${resetInSeconds} seconden opnieuw.`, + resetIn: resetInSeconds + }, + { + status: 429, + headers: { + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(Date.now() / 1000) + resetInSeconds), + 'Retry-After': String(resetInSeconds), + } + } + ) + } + const json = await request.json() const parsed = RequestSchema.safeParse(json) diff --git a/components/docs-chat/docs-chat-widget.tsx b/components/docs-chat/docs-chat-widget.tsx index bbf930a..700d3ce 100644 --- a/components/docs-chat/docs-chat-widget.tsx +++ b/components/docs-chat/docs-chat-widget.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { ChatInput } from './chat-input' import { ChatMessages } from './chat-messages' import { ChatSuggestions } from './chat-suggestions' +import { RateLimitMessage } from './rate-limit-message' import { useDocsChat } from './use-docs-chat' /** @@ -19,7 +20,7 @@ import { useDocsChat } from './use-docs-chat' */ export function DocsChatWidget() { const [isOpen, setIsOpen] = useState(false) - const { messages, isLoading, isStreaming, error, sendMessage, clearError } = useDocsChat() + const { messages, isLoading, isStreaming, error, isRateLimited, rateLimitResetTime, sendMessage, clearError, clearRateLimit } = useDocsChat() return ( <> @@ -106,8 +107,16 @@ export function DocsChatWidget() { {/* Messages */} - {/* Suggestions - show only when just welcome message */} - {messages.length === 1 && messages[0].id === 'welcome' && ( + {/* Rate limit message */} + {isRateLimited && ( + + )} + + {/* Suggestions - show only when just welcome message and not rate limited */} + {!isRateLimited && messages.length === 1 && messages[0].id === 'welcome' && ( diff --git a/components/docs-chat/index.ts b/components/docs-chat/index.ts index 72612e8..38a2999 100644 --- a/components/docs-chat/index.ts +++ b/components/docs-chat/index.ts @@ -2,5 +2,6 @@ export { DocsChatWidget } from './docs-chat-widget' export { ChatMessages } from './chat-messages' export { ChatInput } from './chat-input' export { ChatSuggestions } from './chat-suggestions' +export { RateLimitMessage } from './rate-limit-message' export { useDocsChat } from './use-docs-chat' export type { ChatMessage } from './use-docs-chat' diff --git a/components/docs-chat/rate-limit-message.tsx b/components/docs-chat/rate-limit-message.tsx new file mode 100644 index 0000000..fbd668c --- /dev/null +++ b/components/docs-chat/rate-limit-message.tsx @@ -0,0 +1,78 @@ +'use client' + +import { useEffect, useState } from 'react' +import { AlertTriangle, Clock } from 'lucide-react' + +interface RateLimitMessageProps { + resetTime: number | null + onReset: () => void +} + +/** + * Informative message shown when rate limit is reached + * Includes countdown timer and explains what the chat can be used for + */ +export function RateLimitMessage({ resetTime, onReset }: RateLimitMessageProps) { + const [secondsLeft, setSecondsLeft] = useState(() => { + if (!resetTime) return 0 + return Math.max(0, Math.ceil((resetTime - Date.now()) / 1000)) + }) + + useEffect(() => { + if (!resetTime) return + + const interval = setInterval(() => { + const remaining = Math.max(0, Math.ceil((resetTime - Date.now()) / 1000)) + setSecondsLeft(remaining) + + if (remaining <= 0) { + clearInterval(interval) + onReset() + } + }, 1000) + + return () => clearInterval(interval) + }, [resetTime, onReset]) + + return ( +
+
+
+
+ +
+
+

+ Even pauze +

+

+ Je hebt het maximum aantal vragen voor deze minuut bereikt. + Dit is een demo-omgeving met beperkte capaciteit. +

+ +
+

+ Waar kan deze assistent bij helpen? +

+
    +
  • • Uitleg over EPD-functies en navigatie
  • +
  • • Hoe je intakes, screenings en rapportages maakt
  • +
  • • Hulp bij spraakherkenning en dicteren
  • +
  • • Vragen over cliĆ«ntbeheer en dossiers
  • +
+
+ + {secondsLeft > 0 && ( +
+ + + Je kunt weer vragen stellen over {secondsLeft} {secondsLeft === 1 ? 'seconde' : 'seconden'} + +
+ )} +
+
+
+
+ ) +} diff --git a/components/docs-chat/use-docs-chat.ts b/components/docs-chat/use-docs-chat.ts index 294ff97..16091fa 100644 --- a/components/docs-chat/use-docs-chat.ts +++ b/components/docs-chat/use-docs-chat.ts @@ -19,6 +19,8 @@ interface UseDocsChatState { isLoading: boolean isStreaming: boolean error: string | null + isRateLimited: boolean + rateLimitResetTime: number | null // timestamp when rate limit resets } /** @@ -28,6 +30,7 @@ interface UseDocsChatReturn extends UseDocsChatState { sendMessage: (content: string) => Promise clearMessages: () => void clearError: () => void + clearRateLimit: () => void } /** @@ -66,6 +69,8 @@ export function useDocsChat(): UseDocsChatReturn { isLoading: false, isStreaming: false, error: null, + isRateLimited: false, + rateLimitResetTime: null, }) const sendMessage = useCallback(async (content: string) => { @@ -111,6 +116,20 @@ export function useDocsChat(): UseDocsChatReturn { if (!response.ok) { const errorData = await response.json().catch(() => ({})) + + // Handle rate limit specifically + if (response.status === 429) { + const resetIn = errorData.resetIn ?? 60 // default 60 seconds + setState((prev) => ({ + ...prev, + isLoading: false, + isRateLimited: true, + rateLimitResetTime: Date.now() + (resetIn * 1000), + messages: prev.messages.filter((m) => m.id !== assistantMessage.id), + })) + return + } + throw new Error(errorData.error || `Fout: ${response.status}`) } @@ -192,6 +211,8 @@ export function useDocsChat(): UseDocsChatReturn { isLoading: false, isStreaming: false, error: null, + isRateLimited: false, + rateLimitResetTime: null, }) }, []) @@ -199,10 +220,15 @@ export function useDocsChat(): UseDocsChatReturn { setState((prev) => ({ ...prev, error: null })) }, []) + const clearRateLimit = useCallback(() => { + setState((prev) => ({ ...prev, isRateLimited: false, rateLimitResetTime: null })) + }, []) + return { ...state, sendMessage, clearMessages, clearError, + clearRateLimit, } } diff --git a/content/nl/documentatie/ai-documentatie-assistent.mdx b/content/nl/documentatie/ai-documentatie-assistent.mdx index 2b559ac..b185827 100644 --- a/content/nl/documentatie/ai-documentatie-assistent.mdx +++ b/content/nl/documentatie/ai-documentatie-assistent.mdx @@ -28,17 +28,23 @@ Klik op de **oranje knop met sparkles-icoon** rechtsonder in het scherm. Deze kn **Wat u ziet:** - Een chatvenster opent met een slide-in animatie -- Een welkomstbericht met voorbeeldvragen verschijnt -- De knop verandert in het chatvenster +- Een welkomstbericht verschijnt +- Drie categorieƫn om uit te kiezen: Cliƫnten & Dossiers, Intake & Screening, Spraak & Rapportage -### Stap 2: Stel uw vraag +### Stap 2: Kies een onderwerp of stel een vraag -Typ uw vraag in het tekstveld onderaan het chatvenster. Voorbeeldvragen: +U kunt op twee manieren beginnen: -- "Hoe maak ik een nieuwe cliƫnt aan?" -- "Waar vind ik de screeningresultaten?" -- "Hoe start ik een intake?" -- "Hoe werkt de spraakherkenning?" +**Optie A: Kies een categorie** +Klik op een van de drie categorieƫn om voorbeeldvragen te zien: +- **Cliƫnten & Dossiers** - Vragen over cliƫntbeheer en dossiers +- **Intake & Screening** - Vragen over intakes en screenings +- **Spraak & Rapportage** - Vragen over spraakherkenning en dicteren + +Na het kiezen van een categorie ziet u drie voorbeeldvragen. Klik op een vraag om deze direct te stellen. + +**Optie B: Typ uw eigen vraag** +Typ uw vraag in het tekstveld onderaan het chatvenster. **Tips voor betere antwoorden:** - Stel specifieke vragen over ƩƩn onderwerp @@ -156,6 +162,17 @@ Nee. Gesprekken worden niet permanent opgeslagen: De assistent is geoptimaliseerd voor **Nederlands**. U krijgt ook antwoorden in het Nederlands. +### Waarom zie ik "Even pauze"? + +Dit is een demo-omgeving met een vraaglimiet van **10 vragen per minuut**. Deze limiet voorkomt overbelasting van het systeem. + +**Wat u ziet:** +- Een oranje melding met uitleg over de limiet +- Een countdown timer die aftelt tot u weer vragen kunt stellen +- Een overzicht van onderwerpen waarvoor de assistent geschikt is + +Wacht tot de timer op 0 staat - de chat wordt automatisch weer actief. + --- ## Bekende beperkingen @@ -164,6 +181,7 @@ De assistent is geoptimaliseerd voor **Nederlands**. U krijgt ook antwoorden in - **Geen acties**: De assistent kan niets wijzigen in het systeem - **Sessie-gebonden**: Conversatie verdwijnt na uitloggen - **Tekst-only**: Geen ondersteuning voor afbeeldingen of bestanden +- **Vraaglimiet**: Maximaal 10 vragen per minuut (demo-omgeving) ---