Files
breakpilot-compliance/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx
T
Benjamin Admin 49171e841f feat(advisor): Evidence Workspace — structured panes, markdown, sources as knowledge units
Rebuilds the Compliance Advisor floating widget from a plain chat into an Evidence
Workspace: pinned last question, markdown-rendered answer (clean prose), and separate
panes for Sources (hierarchical Knowledge Units), Figures (C8, conditional) and
Footnotes (C-FN), plus a stats bar (Quellen/Regelwerke/Diagramme/Fußnoten). Scrollable
turn history; stays a floating icon on every SDK page.

Architecture (user direction): the frontend renders ONLY structured evidence and NEVER
parses the answer text. The proxy now returns a JSON AdvisorEvidenceMeta line followed
by the streamed markdown answer; advisor-rag exposes structured results; an adapter maps
RAG/compiler output to the frontend envelope. Figures/footnotes wire in once the
RAG-ingestion contract lands (requested on the board) — figures pane is conditional.

- lib/sdk/advisor/{evidence,evidence-adapter}.ts (+ adapter test, 7 cases)
- components/sdk/advisor/* panes + in-house safe Markdown (no new dep, no dangerouslySetInnerHTML) + test
- useAdvisorStream (meta-line parse + streamed answer) + useAdvisorEmail (escaped)
- proxy: evidence-meta-v1 envelope + clean-prose prompt (no inline citations)
- tsc clean, 11 vitest pass, check-loc 0. ESLint not installed in this node_modules -> CI lints on push.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 07:46:37 +02:00

161 lines
6.1 KiB
TypeScript

'use client'
import { useCallback, useState } from 'react'
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react'
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
import { useAdvisorStream } from './advisor/useAdvisorStream'
import { useAdvisorEmail } from './advisor/useAdvisorEmail'
interface ComplianceAdvisorWidgetProps {
currentStep?: string
}
type Country = 'DE' | 'AT' | 'CH' | 'EU'
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
/**
* Compliance Advisor — Evidence Workspace as a floating widget on every SDK page.
* Renders ONLY structured evidence from the SDK (answer + sources + figures + footnotes);
* it never parses the answer text. See memory: advisor-evidence-workspace-no-parse.
*/
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [inputValue, setInputValue] = useState('')
const [country, setCountry] = useState<Country>('DE')
const { turns, isStreaming, send, stop } = useAdvisorStream({ currentStep, country })
const email = useAdvisorEmail(turns, country, currentStep)
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
const submit = useCallback(
(q: string) => {
if (!q.trim() || isStreaming) return
setInputValue('')
void send(q)
},
[isStreaming, send],
)
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit(inputValue)
}
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] z-50 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-indigo-700"
aria-label="Compliance Advisor oeffnen"
>
<MessagesSquare className="h-6 w-6" />
</button>
)
}
return (
<div
className={`fixed bottom-6 right-6 z-50 flex max-h-screen flex-col rounded-2xl border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${
isExpanded ? 'h-[85vh] w-[760px]' : 'h-[560px] w-[420px]'
}`}
>
{/* Header */}
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<MessagesSquare className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold">Compliance Advisor</div>
<div className="mt-0.5 flex items-center gap-1">
{COUNTRIES.map((c) => (
<button
key={c}
onClick={() => setCountry(c)}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
country === c ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'
}`}
>
{c}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{turns.length > 0 && (
<button
onClick={email.send}
disabled={email.sending}
className={`text-white/80 transition-colors hover:text-white ${email.sent ? 'text-green-300' : ''}`}
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
aria-label="Als Email an DSB senden"
>
{email.sent ? (
<Check className="h-5 w-5" />
) : email.sending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Mail className="h-5 w-5" />
)}
</button>
)}
<button
onClick={() => setIsExpanded((v) => !v)}
className="text-white/80 transition-colors hover:text-white"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
>
{isExpanded ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
</button>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 transition-colors hover:text-white"
aria-label="Schliessen"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Evidence Workspace */}
<EvidenceWorkspace turns={turns} exampleQuestions={exampleQuestions} onExample={submit} />
{/* Input */}
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Frage eingeben..."
disabled={isStreaming}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
/>
{isStreaming ? (
<button
onClick={stop}
className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
title="Generierung stoppen"
>
<Square className="h-5 w-5" />
</button>
) : (
<button
onClick={() => submit(inputValue)}
disabled={!inputValue.trim()}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-5 w-5" />
</button>
)}
</div>
</div>
</div>
)
}