Files
triqura-ecd/components/docs-chat/use-docs-chat.ts
2025-12-02 13:37:45 +01:00

256 lines
6.4 KiB
TypeScript

'use client'
import { useCallback, useState } from 'react'
/**
* Chat message type
*/
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
/**
* Hook state
*/
interface UseDocsChatState {
messages: ChatMessage[]
isLoading: boolean
isStreaming: boolean
error: string | null
isRateLimited: boolean
rateLimitResetTime: number | null // timestamp when rate limit resets
}
/**
* Hook options
*/
interface UseDocsChatOptions {
clientId?: string // UUID van actieve patiënt
clientName?: string // Naam van actieve patiënt (voor display)
}
/**
* Hook return type
*/
interface UseDocsChatReturn extends UseDocsChatState {
sendMessage: (content: string) => Promise<void>
clearMessages: () => void
clearError: () => void
clearRateLimit: () => void
hasClientContext: boolean
clientName: string | null
}
/**
* Generate unique message ID
*/
function generateId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
/**
* Welcome message shown on first load
*/
const WELCOME_MESSAGE: ChatMessage = {
id: 'welcome',
role: 'assistant',
content:
'Hallo! Ik ben je EPD assistent. Stel me een vraag over het systeem of kies een onderwerp hieronder.',
}
/**
* Custom hook for docs chat functionality
*
* Features:
* - Message state management
* - Streaming responses from Claude API
* - Loading and error states
* - Welcome message on init
* - Client-aware: sends clientId for patient-specific questions
*
* @example
* // Documentation-only mode
* const { messages, isLoading, sendMessage } = useDocsChat()
*
* // Client-aware mode
* const { messages, sendMessage, hasClientContext } = useDocsChat({
* clientId: patient?.id,
* clientName: "Jan de Vries"
* })
*/
export function useDocsChat(options?: UseDocsChatOptions): UseDocsChatReturn {
const { clientId, clientName } = options ?? {}
const [state, setState] = useState<UseDocsChatState>({
messages: [WELCOME_MESSAGE],
isLoading: false,
isStreaming: false,
error: null,
isRateLimited: false,
rateLimitResetTime: null,
})
const sendMessage = useCallback(async (content: string) => {
const trimmedContent = content.trim()
if (!trimmedContent) return
// Add user message
const userMessage: ChatMessage = {
id: generateId(),
role: 'user',
content: trimmedContent,
}
// Prepare assistant message placeholder
const assistantMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
}
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage, assistantMessage],
isLoading: true,
isStreaming: false,
error: null,
}))
try {
// Get message history (excluding welcome and current messages)
const history = state.messages
.filter((m) => m.id !== 'welcome')
.map((m) => ({ role: m.role, content: m.content }))
const response = await fetch('/api/docs/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: history,
userMessage: trimmedContent,
clientId, // Include clientId if available for patient-specific questions
}),
})
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}`)
}
if (!response.body) {
throw new Error('Geen response body ontvangen')
}
// Start streaming
setState((prev) => ({
...prev,
isLoading: false,
isStreaming: true,
}))
const reader = response.body.getReader()
const decoder = new TextDecoder()
let accumulatedContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
// Handle content_block_delta events
if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
accumulatedContent += parsed.delta.text
setState((prev) => ({
...prev,
messages: prev.messages.map((m) =>
m.id === assistantMessage.id
? { ...m, content: accumulatedContent }
: m
),
}))
}
} catch {
// Ignore parse errors for non-JSON lines
}
}
}
// Streaming complete
setState((prev) => ({
...prev,
isStreaming: false,
}))
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Er ging iets mis'
setState((prev) => ({
...prev,
isLoading: false,
isStreaming: false,
error: errorMessage,
// Remove empty assistant message on error
messages: prev.messages.filter(
(m) => m.id !== assistantMessage.id || m.content.length > 0
),
}))
}
}, [state.messages, clientId])
const clearMessages = useCallback(() => {
setState({
messages: [WELCOME_MESSAGE],
isLoading: false,
isStreaming: false,
error: null,
isRateLimited: false,
rateLimitResetTime: null,
})
}, [])
const clearError = useCallback(() => {
setState((prev) => ({ ...prev, error: null }))
}, [])
const clearRateLimit = useCallback(() => {
setState((prev) => ({ ...prev, isRateLimited: false, rateLimitResetTime: null }))
}, [])
return {
...state,
sendMessage,
clearMessages,
clearError,
clearRateLimit,
hasClientContext: !!clientId,
clientName: clientName ?? null,
}
}