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:
98
components/docs-chat/chat-input.tsx
Normal file
98
components/docs-chat/chat-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
components/docs-chat/chat-messages.tsx
Normal file
150
components/docs-chat/chat-messages.tsx
Normal 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" />
|
||||
)
|
||||
}
|
||||
116
components/docs-chat/docs-chat-widget.tsx
Normal file
116
components/docs-chat/docs-chat-widget.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
components/docs-chat/index.ts
Normal file
5
components/docs-chat/index.ts
Normal 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'
|
||||
208
components/docs-chat/use-docs-chat.ts
Normal file
208
components/docs-chat/use-docs-chat.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user