Extends the Compliance Advisor from a Q&A chatbot into a full drafting engine that can generate, validate, and refine compliance documents within Scope Engine constraints. Includes intent classifier, state projector, constraint enforcer, SOUL templates, Go backend endpoints, and React UI components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface Message {
|
|
id: string
|
|
role: 'user' | 'agent'
|
|
content: string
|
|
timestamp: Date
|
|
}
|
|
|
|
interface ComplianceAdvisorWidgetProps {
|
|
currentStep?: string
|
|
enableDraftingEngine?: boolean
|
|
}
|
|
|
|
// =============================================================================
|
|
// EXAMPLE QUESTIONS BY STEP
|
|
// =============================================================================
|
|
|
|
const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
|
vvt: [
|
|
'Was ist ein Verarbeitungsverzeichnis?',
|
|
'Welche Informationen muss ich erfassen?',
|
|
'Wie dokumentiere ich die Rechtsgrundlage?',
|
|
],
|
|
'compliance-scope': [
|
|
'Was bedeutet L3?',
|
|
'Wann brauche ich eine DSFA?',
|
|
'Was ist der Unterschied zwischen L2 und L3?',
|
|
],
|
|
tom: [
|
|
'Was sind TOM?',
|
|
'Welche Massnahmen sind erforderlich?',
|
|
'Wie dokumentiere ich Verschluesselung?',
|
|
],
|
|
dsfa: [
|
|
'Was ist eine DSFA?',
|
|
'Wann ist eine DSFA verpflichtend?',
|
|
'Wie bewerte ich Risiken?',
|
|
],
|
|
loeschfristen: [
|
|
'Wie definiere ich Loeschfristen?',
|
|
'Was ist der Unterschied zwischen Loeschpflicht und Aufbewahrungspflicht?',
|
|
'Wann muss ich Daten loeschen?',
|
|
],
|
|
default: [
|
|
'Wie starte ich mit dem SDK?',
|
|
'Was ist der erste Schritt?',
|
|
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
|
|
],
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENT
|
|
// =============================================================================
|
|
|
|
export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftingEngine = false }: ComplianceAdvisorWidgetProps) {
|
|
// Feature-flag: If Drafting Engine enabled, render DraftingEngineWidget instead
|
|
if (enableDraftingEngine) {
|
|
const { DraftingEngineWidget } = require('./DraftingEngineWidget')
|
|
return <DraftingEngineWidget currentStep={currentStep} enableDraftingEngine />
|
|
}
|
|
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
const [inputValue, setInputValue] = useState('')
|
|
const [isTyping, setIsTyping] = useState(false)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const abortControllerRef = useRef<AbortController | null>(null)
|
|
|
|
// Get example questions for current step
|
|
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
|
|
|
|
// Auto-scroll to bottom when messages change
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
// Cleanup abort controller on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
abortControllerRef.current?.abort()
|
|
}
|
|
}, [])
|
|
|
|
// Handle send message with real LLM + RAG
|
|
const handleSendMessage = useCallback(
|
|
async (content: string) => {
|
|
if (!content.trim() || isTyping) return
|
|
|
|
const userMessage: Message = {
|
|
id: `msg-${Date.now()}`,
|
|
role: 'user',
|
|
content: content.trim(),
|
|
timestamp: new Date(),
|
|
}
|
|
|
|
setMessages((prev) => [...prev, userMessage])
|
|
setInputValue('')
|
|
setIsTyping(true)
|
|
|
|
const agentMessageId = `msg-${Date.now()}-agent`
|
|
|
|
// Create abort controller for this request
|
|
abortControllerRef.current = new AbortController()
|
|
|
|
try {
|
|
// Build conversation history for context
|
|
const history = messages.map((m) => ({
|
|
role: m.role === 'user' ? 'user' : 'assistant',
|
|
content: m.content,
|
|
}))
|
|
|
|
const response = await fetch('/api/sdk/compliance-advisor/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
message: content.trim(),
|
|
history,
|
|
currentStep,
|
|
}),
|
|
signal: abortControllerRef.current.signal,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }))
|
|
throw new Error(errorData.error || `Server-Fehler (${response.status})`)
|
|
}
|
|
|
|
// Add empty agent message for streaming
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: agentMessageId,
|
|
role: 'agent',
|
|
content: '',
|
|
timestamp: new Date(),
|
|
},
|
|
])
|
|
|
|
// Read streaming response
|
|
const reader = response.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let accumulated = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
accumulated += decoder.decode(value, { stream: true })
|
|
|
|
// Update agent message with accumulated content
|
|
const currentText = accumulated
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
|
|
)
|
|
|
|
// Auto-scroll during streaming
|
|
requestAnimationFrame(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
})
|
|
}
|
|
|
|
setIsTyping(false)
|
|
} catch (error) {
|
|
if ((error as Error).name === 'AbortError') {
|
|
// User cancelled, keep partial response
|
|
setIsTyping(false)
|
|
return
|
|
}
|
|
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
|
|
|
|
// Add or update agent message with error
|
|
setMessages((prev) => {
|
|
const hasAgent = prev.some((m) => m.id === agentMessageId)
|
|
if (hasAgent) {
|
|
return prev.map((m) =>
|
|
m.id === agentMessageId
|
|
? { ...m, content: `Fehler: ${errorMessage}` }
|
|
: m
|
|
)
|
|
}
|
|
return [
|
|
...prev,
|
|
{
|
|
id: agentMessageId,
|
|
role: 'agent' as const,
|
|
content: `Fehler: ${errorMessage}`,
|
|
timestamp: new Date(),
|
|
},
|
|
]
|
|
})
|
|
setIsTyping(false)
|
|
}
|
|
},
|
|
[isTyping, messages, currentStep]
|
|
)
|
|
|
|
// Handle stop generation
|
|
const handleStopGeneration = useCallback(() => {
|
|
abortControllerRef.current?.abort()
|
|
setIsTyping(false)
|
|
}, [])
|
|
|
|
// Handle example question click
|
|
const handleExampleClick = (question: string) => {
|
|
handleSendMessage(question)
|
|
}
|
|
|
|
// Handle key press
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSendMessage(inputValue)
|
|
}
|
|
}
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
|
aria-label="Compliance Advisor oeffnen"
|
|
>
|
|
<svg
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold text-sm">Compliance Advisor</div>
|
|
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="text-white/80 hover:text-white transition-colors"
|
|
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
{isExpanded ? (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4"
|
|
/>
|
|
) : (
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5"
|
|
/>
|
|
)}
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => setIsOpen(false)}
|
|
className="text-white/80 hover:text-white transition-colors"
|
|
aria-label="Schliessen"
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages Area */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
|
|
{messages.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<svg
|
|
className="w-8 h-8 text-purple-600"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-sm font-medium text-gray-900 mb-2">
|
|
Willkommen beim Compliance Advisor
|
|
</h3>
|
|
<p className="text-xs text-gray-500 mb-4">
|
|
Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.
|
|
</p>
|
|
|
|
{/* Example Questions */}
|
|
<div className="text-left space-y-2">
|
|
<p className="text-xs font-medium text-gray-700 mb-2">
|
|
Beispielfragen:
|
|
</p>
|
|
{exampleQuestions.map((question, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => handleExampleClick(question)}
|
|
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
|
|
>
|
|
{question}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{messages.map((message) => (
|
|
<div
|
|
key={message.id}
|
|
className={`flex ${
|
|
message.role === 'user' ? 'justify-end' : 'justify-start'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`max-w-[80%] rounded-lg px-3 py-2 ${
|
|
message.role === 'user'
|
|
? 'bg-indigo-600 text-white'
|
|
: 'bg-white border border-gray-200 text-gray-800'
|
|
}`}
|
|
>
|
|
<p
|
|
className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}
|
|
>
|
|
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
|
|
</p>
|
|
<p
|
|
className={`text-xs mt-1 ${
|
|
message.role === 'user'
|
|
? 'text-indigo-200'
|
|
: 'text-gray-400'
|
|
}`}
|
|
>
|
|
{message.timestamp.toLocaleTimeString('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{isTyping && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
|
|
<div className="flex space-x-1">
|
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: '0.1s' }}
|
|
/>
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: '0.2s' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Frage eingeben..."
|
|
disabled={isTyping}
|
|
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
|
|
/>
|
|
{isTyping ? (
|
|
<button
|
|
onClick={handleStopGeneration}
|
|
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
|
title="Generierung stoppen"
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 6h12v12H6z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleSendMessage(inputValue)}
|
|
disabled={!inputValue.trim()}
|
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|