feat: add rate limiting with countdown timer for docs chat
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,40 @@ const DOCS_ASSISTANT_MODEL = process.env.DOCS_ASSISTANT_MODEL ?? 'claude-sonnet-
|
|||||||
const MAX_HISTORY_MESSAGES = 10
|
const MAX_HISTORY_MESSAGES = 10
|
||||||
const MAX_USER_MESSAGE_LENGTH = 2000
|
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<string, { count: number; resetTime: number }>()
|
||||||
|
|
||||||
|
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({
|
const ChatMessageSchema = z.object({
|
||||||
role: z.enum(['user', 'assistant']),
|
role: z.enum(['user', 'assistant']),
|
||||||
content: z.string().min(1).max(4000),
|
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 })
|
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 json = await request.json()
|
||||||
const parsed = RequestSchema.safeParse(json)
|
const parsed = RequestSchema.safeParse(json)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { ChatInput } from './chat-input'
|
import { ChatInput } from './chat-input'
|
||||||
import { ChatMessages } from './chat-messages'
|
import { ChatMessages } from './chat-messages'
|
||||||
import { ChatSuggestions } from './chat-suggestions'
|
import { ChatSuggestions } from './chat-suggestions'
|
||||||
|
import { RateLimitMessage } from './rate-limit-message'
|
||||||
import { useDocsChat } from './use-docs-chat'
|
import { useDocsChat } from './use-docs-chat'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +20,7 @@ import { useDocsChat } from './use-docs-chat'
|
|||||||
*/
|
*/
|
||||||
export function DocsChatWidget() {
|
export function DocsChatWidget() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -106,8 +107,16 @@ export function DocsChatWidget() {
|
|||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<ChatMessages messages={messages} isStreaming={isStreaming} />
|
<ChatMessages messages={messages} isStreaming={isStreaming} />
|
||||||
|
|
||||||
{/* Suggestions - show only when just welcome message */}
|
{/* Rate limit message */}
|
||||||
{messages.length === 1 && messages[0].id === 'welcome' && (
|
{isRateLimited && (
|
||||||
|
<RateLimitMessage
|
||||||
|
resetTime={rateLimitResetTime}
|
||||||
|
onReset={clearRateLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggestions - show only when just welcome message and not rate limited */}
|
||||||
|
{!isRateLimited && messages.length === 1 && messages[0].id === 'welcome' && (
|
||||||
<ChatSuggestions
|
<ChatSuggestions
|
||||||
onSelect={sendMessage}
|
onSelect={sendMessage}
|
||||||
disabled={isLoading || isStreaming}
|
disabled={isLoading || isStreaming}
|
||||||
@@ -117,7 +126,7 @@ export function DocsChatWidget() {
|
|||||||
{/* Input */}
|
{/* Input */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
disabled={isLoading || isStreaming}
|
disabled={isLoading || isStreaming || isRateLimited}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export { DocsChatWidget } from './docs-chat-widget'
|
|||||||
export { ChatMessages } from './chat-messages'
|
export { ChatMessages } from './chat-messages'
|
||||||
export { ChatInput } from './chat-input'
|
export { ChatInput } from './chat-input'
|
||||||
export { ChatSuggestions } from './chat-suggestions'
|
export { ChatSuggestions } from './chat-suggestions'
|
||||||
|
export { RateLimitMessage } from './rate-limit-message'
|
||||||
export { useDocsChat } from './use-docs-chat'
|
export { useDocsChat } from './use-docs-chat'
|
||||||
export type { ChatMessage } from './use-docs-chat'
|
export type { ChatMessage } from './use-docs-chat'
|
||||||
|
|||||||
78
components/docs-chat/rate-limit-message.tsx
Normal file
78
components/docs-chat/rate-limit-message.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-amber-900 text-sm mb-1">
|
||||||
|
Even pauze
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-amber-800 mb-3">
|
||||||
|
Je hebt het maximum aantal vragen voor deze minuut bereikt.
|
||||||
|
Dit is een demo-omgeving met beperkte capaciteit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-white/60 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-xs font-medium text-slate-700 mb-2">
|
||||||
|
Waar kan deze assistent bij helpen?
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-slate-600 space-y-1">
|
||||||
|
<li>• Uitleg over EPD-functies en navigatie</li>
|
||||||
|
<li>• Hoe je intakes, screenings en rapportages maakt</li>
|
||||||
|
<li>• Hulp bij spraakherkenning en dicteren</li>
|
||||||
|
<li>• Vragen over cliëntbeheer en dossiers</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{secondsLeft > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-amber-700">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
Je kunt weer vragen stellen over <strong>{secondsLeft}</strong> {secondsLeft === 1 ? 'seconde' : 'seconden'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ interface UseDocsChatState {
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isStreaming: boolean
|
isStreaming: boolean
|
||||||
error: string | null
|
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<void>
|
sendMessage: (content: string) => Promise<void>
|
||||||
clearMessages: () => void
|
clearMessages: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
|
clearRateLimit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +69,8 @@ export function useDocsChat(): UseDocsChatReturn {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitResetTime: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendMessage = useCallback(async (content: string) => {
|
const sendMessage = useCallback(async (content: string) => {
|
||||||
@@ -111,6 +116,20 @@ export function useDocsChat(): UseDocsChatReturn {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
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}`)
|
throw new Error(errorData.error || `Fout: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +211,8 @@ export function useDocsChat(): UseDocsChatReturn {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitResetTime: null,
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -199,10 +220,15 @@ export function useDocsChat(): UseDocsChatReturn {
|
|||||||
setState((prev) => ({ ...prev, error: null }))
|
setState((prev) => ({ ...prev, error: null }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const clearRateLimit = useCallback(() => {
|
||||||
|
setState((prev) => ({ ...prev, isRateLimited: false, rateLimitResetTime: null }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
clearError,
|
clearError,
|
||||||
|
clearRateLimit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,17 +28,23 @@ Klik op de **oranje knop met sparkles-icoon** rechtsonder in het scherm. Deze kn
|
|||||||
|
|
||||||
**Wat u ziet:**
|
**Wat u ziet:**
|
||||||
- Een chatvenster opent met een slide-in animatie
|
- Een chatvenster opent met een slide-in animatie
|
||||||
- Een welkomstbericht met voorbeeldvragen verschijnt
|
- Een welkomstbericht verschijnt
|
||||||
- De knop verandert in het chatvenster
|
- 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?"
|
**Optie A: Kies een categorie**
|
||||||
- "Waar vind ik de screeningresultaten?"
|
Klik op een van de drie categorieën om voorbeeldvragen te zien:
|
||||||
- "Hoe start ik een intake?"
|
- **Cliënten & Dossiers** - Vragen over cliëntbeheer en dossiers
|
||||||
- "Hoe werkt de spraakherkenning?"
|
- **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:**
|
**Tips voor betere antwoorden:**
|
||||||
- Stel specifieke vragen over één onderwerp
|
- 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.
|
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
|
## 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
|
- **Geen acties**: De assistent kan niets wijzigen in het systeem
|
||||||
- **Sessie-gebonden**: Conversatie verdwijnt na uitloggen
|
- **Sessie-gebonden**: Conversatie verdwijnt na uitloggen
|
||||||
- **Tekst-only**: Geen ondersteuning voor afbeeldingen of bestanden
|
- **Tekst-only**: Geen ondersteuning voor afbeeldingen of bestanden
|
||||||
|
- **Vraaglimiet**: Maximaal 10 vragen per minuut (demo-omgeving)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user