feat: add docs chat widget UI components (E3)

- Add useDocsChat hook with message state, streaming, error handling
- Add ChatMessages component with auto-scroll and streaming cursor
- Add ChatInput component with Enter/Shift+Enter support
- Add DocsChatWidget floating container with amber styling
- Add AI integration specs (PRD, FO, bouwplan)

Implements Epic 3 of AI Documentatie Assistent feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-01 15:45:44 +01:00
parent 183021718a
commit 4775497dc7
9 changed files with 2036 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
'use client'
import { useRef, useState } from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ChatInputProps {
onSend: (message: string) => void
disabled?: boolean
placeholder?: string
}
/**
* Chat input component with textarea, send button, Enter/Shift+Enter support
*
* - Enter: send message
* - Shift+Enter: new line
* - Auto-resize textarea
*/
export function ChatInput({
onSend,
disabled = false,
placeholder = 'Stel een vraag...',
}: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSubmit = () => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value)
// Auto-resize
const textarea = e.target
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}
const canSend = value.trim().length > 0 && !disabled
return (
<div className="border-t border-slate-200 p-3 bg-white">
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'flex-1 resize-none rounded-xl border border-slate-200 px-4 py-2.5',
'text-sm placeholder:text-slate-400',
'focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500',
'disabled:opacity-50 disabled:cursor-not-allowed',
'max-h-[120px]'
)}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!canSend}
className={cn(
'flex items-center justify-center',
'w-10 h-10 rounded-xl',
'transition-all duration-200',
canSend
? 'bg-amber-500 hover:bg-amber-600 text-white shadow-sm'
: 'bg-slate-100 text-slate-400 cursor-not-allowed'
)}
aria-label="Verstuur bericht"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import type { ChatMessage } from './use-docs-chat'
interface ChatMessagesProps {
messages: ChatMessage[]
isStreaming: boolean
}
/**
* Message list component with auto-scroll and streaming cursor
*
* UI Specs (uit FO):
* - User messages: rechts, bg-amber-100, rounded
* - Assistant messages: links, bg-slate-100, rounded
* - Streaming: pulserende cursor ▊
*/
export function ChatMessages({ messages, isStreaming }: ChatMessagesProps) {
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 space-y-3"
>
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isStreaming={isStreaming && message.role === 'assistant' && message === messages[messages.length - 1]}
/>
))}
</div>
)
}
interface MessageBubbleProps {
message: ChatMessage
isStreaming: boolean
}
function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
const isUser = message.role === 'user'
return (
<div
className={cn(
'flex',
isUser ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[85%] rounded-2xl px-4 py-2 text-sm',
isUser
? 'bg-amber-100 text-amber-900 rounded-br-md'
: 'bg-slate-100 text-slate-900 rounded-bl-md'
)}
>
<MessageContent content={message.content} isStreaming={isStreaming} />
</div>
</div>
)
}
interface MessageContentProps {
content: string
isStreaming: boolean
}
function MessageContent({ content, isStreaming }: MessageContentProps) {
if (!content && isStreaming) {
return <StreamingCursor />
}
// Split content into paragraphs and render with proper spacing
const paragraphs = content.split('\n\n')
return (
<div className="space-y-2">
{paragraphs.map((paragraph, idx) => (
<p key={idx} className="whitespace-pre-wrap leading-relaxed">
{renderInlineFormatting(paragraph)}
{isStreaming && idx === paragraphs.length - 1 && <StreamingCursor />}
</p>
))}
</div>
)
}
/**
* Render basic inline formatting (bold, bullet points)
*/
function renderInlineFormatting(text: string) {
// Handle bullet points
const lines = text.split('\n')
return lines.map((line, idx) => {
const isBullet = line.startsWith('• ') || line.startsWith('- ')
if (isBullet) {
return (
<span key={idx} className="block pl-2">
{line}
{idx < lines.length - 1 && '\n'}
</span>
)
}
// Handle **bold** text
const parts = line.split(/(\*\*[^*]+\*\*)/)
const formatted = parts.map((part, partIdx) => {
if (part.startsWith('**') && part.endsWith('**')) {
return (
<strong key={partIdx} className="font-semibold">
{part.slice(2, -2)}
</strong>
)
}
return part
})
return (
<span key={idx}>
{formatted}
{idx < lines.length - 1 && '\n'}
</span>
)
})
}
/**
* Pulsating streaming cursor
*/
function StreamingCursor() {
return (
<span className="inline-block w-2 h-4 ml-0.5 bg-amber-500 animate-pulse rounded-sm" />
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useState } from 'react'
import { Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ChatInput } from './chat-input'
import { ChatMessages } from './chat-messages'
import { useDocsChat } from './use-docs-chat'
/**
* Floating chat widget for documentation assistant
*
* UI Specs (uit FO):
* - Trigger button: 56x56px, amber gradient, Sparkles icon, fixed bottom-6 right-6
* - Panel: 384px breed, max 80vh, slide-in animatie
*/
export function DocsChatWidget() {
const [isOpen, setIsOpen] = useState(false)
const { messages, isLoading, isStreaming, error, sendMessage, clearError } = useDocsChat()
return (
<>
{/* Floating trigger button */}
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn(
'fixed bottom-6 right-6 z-50',
'w-14 h-14 rounded-full',
'bg-gradient-to-br from-amber-400 to-amber-600',
'text-white shadow-lg',
'flex items-center justify-center',
'hover:from-amber-500 hover:to-amber-700',
'hover:scale-105 hover:shadow-xl',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2',
isOpen && 'scale-0 opacity-0'
)}
aria-label="Open documentatie assistent"
>
<Sparkles className="w-6 h-6" />
</button>
{/* Chat panel */}
<div
className={cn(
'fixed bottom-6 right-6 z-50',
'w-96 max-h-[80vh]',
'bg-white rounded-2xl shadow-2xl',
'flex flex-col overflow-hidden',
'border border-slate-200',
'transition-all duration-300 ease-out',
isOpen
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-4 scale-95 pointer-events-none'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-gradient-to-r from-amber-50 to-amber-100/50">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div>
<div>
<h2 className="font-semibold text-slate-900 text-sm">
Documentatie Assistent
</h2>
<p className="text-xs text-slate-500">
Stel vragen over het EPD
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className={cn(
'w-8 h-8 rounded-lg',
'flex items-center justify-center',
'text-slate-400 hover:text-slate-600',
'hover:bg-slate-100',
'transition-colors duration-150'
)}
aria-label="Sluit chat"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Error banner */}
{error && (
<div className="px-4 py-2 bg-red-50 border-b border-red-100 flex items-center justify-between">
<p className="text-sm text-red-600">{error}</p>
<button
type="button"
onClick={clearError}
className="text-red-400 hover:text-red-600 text-xs"
>
Sluiten
</button>
</div>
)}
{/* Messages */}
<ChatMessages messages={messages} isStreaming={isStreaming} />
{/* Input */}
<ChatInput
onSend={sendMessage}
disabled={isLoading || isStreaming}
/>
</div>
</>
)
}

View File

@@ -0,0 +1,5 @@
export { DocsChatWidget } from './docs-chat-widget'
export { ChatMessages } from './chat-messages'
export { ChatInput } from './chat-input'
export { useDocsChat } from './use-docs-chat'
export type { ChatMessage } from './use-docs-chat'

View File

@@ -0,0 +1,208 @@
'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
}
/**
* Hook return type
*/
interface UseDocsChatReturn extends UseDocsChatState {
sendMessage: (content: string) => Promise<void>
clearMessages: () => void
clearError: () => void
}
/**
* 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 de documentatie assistent voor het Mini-EPD. Stel me een vraag over het systeem, bijvoorbeeld:\n\n• Hoe maak ik een intake aan?\n• Hoe werkt de spraakherkenning?\n• Waar vind ik het behandelplan?',
}
/**
* Custom hook for docs chat functionality
*
* Features:
* - Message state management
* - Streaming responses from Claude API
* - Loading and error states
* - Welcome message on init
*
* @example
* const { messages, isLoading, sendMessage } = useDocsChat()
* await sendMessage("Hoe maak ik een intake aan?")
*/
export function useDocsChat(): UseDocsChatReturn {
const [state, setState] = useState<UseDocsChatState>({
messages: [WELCOME_MESSAGE],
isLoading: false,
isStreaming: false,
error: 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,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
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])
const clearMessages = useCallback(() => {
setState({
messages: [WELCOME_MESSAGE],
isLoading: false,
isStreaming: false,
error: null,
})
}, [])
const clearError = useCallback(() => {
setState((prev) => ({ ...prev, error: null }))
}, [])
return {
...state,
sendMessage,
clearMessages,
clearError,
}
}