merge(admin): FE advisor widget — topic threads, per-question delete/copy, fullscreen
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 3s
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m0s
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

This commit is contained in:
Claude
2026-07-01 19:07:48 +02:00
11 changed files with 611 additions and 71 deletions
@@ -75,12 +75,28 @@ function answerSystem(
return s return s
} }
// Prior thread turns for contextual follow-ups. Validated + bounded (last 8 turns ~ 4 Q&A).
function parseHistory(raw: unknown): ChatMessage[] {
if (!Array.isArray(raw)) return []
const turns: ChatMessage[] = []
for (const t of raw) {
if (!t || typeof t !== 'object') continue
const role = (t as { role?: unknown }).role
const content = (t as { content?: unknown }).content
if ((role === 'user' || role === 'assistant') && typeof content === 'string' && content.trim()) {
turns.push({ role, content })
}
}
return turns.slice(-8)
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const question = String(body.question ?? body.message ?? '').trim() const question = String(body.question ?? body.message ?? '').trim()
const context: string | null = body.context ?? null const context: string | null = body.context ?? null
const audience = typeof body.audience === 'string' ? body.audience.trim() : '' const audience = typeof body.audience === 'string' ? body.audience.trim() : ''
const history = parseHistory(body.history)
const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country) const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country)
? (body.country as Country) ? (body.country as Country)
: undefined : undefined
@@ -98,6 +114,7 @@ export async function POST(request: NextRequest) {
const legacySoul = await readSoulFile('compliance-advisor') const legacySoul = await readSoulFile('compliance-advisor')
const legacyStream = await streamAdvisorAnswer([ const legacyStream = await streamAdvisorAnswer([
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) }, { role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) },
...history,
{ role: 'user', content: question }, { role: 'user', content: question },
]) ])
if (!legacyStream) { if (!legacyStream) {
@@ -141,6 +158,7 @@ export async function POST(request: NextRequest) {
const soul = await readSoulFile('compliance-advisor') const soul = await readSoulFile('compliance-advisor')
const messages: ChatMessage[] = [ const messages: ChatMessage[] = [
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) }, { role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) },
...history,
{ role: 'user', content: question }, { role: 'user', content: question },
] ]
const answer = await completeAdvisorAnswer(messages) const answer = await completeAdvisorAnswer(messages)
@@ -1,7 +1,20 @@
'use client' 'use client'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react' import {
Check,
Expand,
Loader2,
Mail,
Maximize2,
MessagesSquare,
Minimize2,
Plus,
Send,
Shrink,
Square,
X,
} from 'lucide-react'
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState' import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace' import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
import { useAdvisorCase } from './advisor/useAdvisorCase' import { useAdvisorCase } from './advisor/useAdvisorCase'
@@ -14,20 +27,29 @@ interface ComplianceAdvisorWidgetProps {
type Country = 'DE' | 'AT' | 'CH' | 'EU' type Country = 'DE' | 'AT' | 'CH' | 'EU'
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU'] const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
type View = 'compact' | 'panel' | 'fullscreen'
const SIZE: Record<View, string> = {
compact: 'bottom-6 right-6 h-[560px] w-[420px] rounded-2xl',
panel: 'bottom-6 right-6 h-[85vh] w-[960px] rounded-2xl',
fullscreen: 'inset-0 h-full w-full',
}
/** /**
* Compliance Advisor — a floating Case Workspace on every SDK page. * Compliance Advisor — a floating Case Workspace on every SDK page (compact / panel / fullscreen).
* Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text. * Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text.
* See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract. * See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract.
*/ */
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) { export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false) const [view, setView] = useState<View>('compact')
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [country, setCountry] = useState<Country>('DE') const [country, setCountry] = useState<Country>('DE')
const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country }) const { cases, threads, busy, activeCaseId, ask, newTopic, selectContext, selectCase, remove, stop } =
useAdvisorCase({ currentStep, country })
const email = useAdvisorEmail(cases, country, currentStep) const email = useAdvisorEmail(cases, country, currentStep)
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
const expanded = view !== 'compact'
const submit = useCallback( const submit = useCallback(
(q: string) => { (q: string) => {
@@ -38,6 +60,15 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
[busy, ask], [busy, ask],
) )
const submitNewTopic = useCallback(
(q: string) => {
if (!q.trim() || busy) return
setInputValue('')
newTopic(q)
},
[busy, newTopic],
)
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
@@ -57,13 +88,14 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
) )
} }
const headRound = view === 'fullscreen' ? '' : 'rounded-t-2xl'
const footRound = view === 'fullscreen' ? '' : 'rounded-b-2xl'
return ( return (
<div <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 ${ className={`fixed z-50 flex max-h-screen flex-col border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${SIZE[view]}`}
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
}`}
> >
<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 justify-between bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white ${headRound}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<MessagesSquare className="h-5 w-5" /> <MessagesSquare className="h-5 w-5" />
@@ -97,12 +129,22 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
{email.sent ? <Check className="h-5 w-5" /> : email.sending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Mail className="h-5 w-5" />} {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>
)} )}
{view !== 'fullscreen' && (
<button
onClick={() => setView((v) => (v === 'compact' ? 'panel' : 'compact'))}
className="text-white/80 transition-colors hover:text-white"
aria-label={view === 'compact' ? 'Vergroessern' : 'Verkleinern'}
>
{view === 'compact' ? <Maximize2 className="h-5 w-5" /> : <Minimize2 className="h-5 w-5" />}
</button>
)}
<button <button
onClick={() => setIsExpanded((v) => !v)} onClick={() => setView((v) => (v === 'fullscreen' ? 'panel' : 'fullscreen'))}
className="text-white/80 transition-colors hover:text-white" className="text-white/80 transition-colors hover:text-white"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'} aria-label={view === 'fullscreen' ? 'Vollbild verlassen' : 'Vollbild'}
title={view === 'fullscreen' ? 'Vollbild verlassen' : 'Vollbild'}
> >
{isExpanded ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />} {view === 'fullscreen' ? <Shrink className="h-5 w-5" /> : <Expand className="h-5 w-5" />}
</button> </button>
<button <button
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
@@ -116,21 +158,25 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<EvidenceWorkspace <EvidenceWorkspace
cases={cases} cases={cases}
expanded={isExpanded} threads={threads}
expanded={expanded}
busy={busy} busy={busy}
activeCaseId={activeCaseId}
exampleQuestions={exampleQuestions} exampleQuestions={exampleQuestions}
onExample={submit} onExample={submit}
onSelectContext={selectContext} onSelectContext={selectContext}
onSelectCase={selectCase}
onRemove={remove}
/> />
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3"> <div className={`border-t border-gray-200 bg-white p-3 ${footRound}`}>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Frage eingeben..." placeholder={cases.length > 0 ? 'Folgefrage eingeben...' : 'Frage eingeben...'}
disabled={busy} disabled={busy}
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" 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"
/> />
@@ -139,13 +185,27 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<Square className="h-5 w-5" /> <Square className="h-5 w-5" />
</button> </button>
) : ( ) : (
<button <>
onClick={() => submit(inputValue)} {cases.length > 0 && (
disabled={!inputValue.trim()} <button
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" onClick={() => submitNewTopic(inputValue)}
> disabled={!inputValue.trim()}
<Send className="h-5 w-5" /> className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
</button> title="Als neues Thema stellen"
aria-label="Neues Thema"
>
<Plus 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"
title={cases.length > 0 ? 'Folgefrage senden' : 'Frage senden'}
>
<Send className="h-5 w-5" />
</button>
</>
)} )}
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@ const answer: AdvisorResponse = {
} }
function mk(response: AdvisorResponse): AdvisorCase { function mk(response: AdvisorResponse): AdvisorCase {
return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' } return { id: 'case1', threadId: 'thread1', question: response.question, response, selectedContext: null, status: 'done' }
} }
describe('CaseView — clarify mode', () => { describe('CaseView — clarify mode', () => {
@@ -1,6 +1,8 @@
'use client' 'use client'
import { Check, Copy, Trash2 } from 'lucide-react'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
import { formatCaseForCopy } from '@/lib/sdk/advisor/copy'
import type { AdvisorCase } from './useAdvisorCase' import type { AdvisorCase } from './useAdvisorCase'
import { ClarifyView } from './ClarifyView' import { ClarifyView } from './ClarifyView'
import { EvidenceSummary } from './EvidenceSummary' import { EvidenceSummary } from './EvidenceSummary'
@@ -9,6 +11,7 @@ import { VisualEvidencePane } from './VisualEvidencePane'
import { FootnotesPane } from './FootnotesPane' import { FootnotesPane } from './FootnotesPane'
import { Markdown } from './Markdown' import { Markdown } from './Markdown'
import { useCitationHighlight } from './useCitationHighlight' import { useCitationHighlight } from './useCitationHighlight'
import { useClipboard } from './useClipboard'
export function LoadingDots() { export function LoadingDots() {
return ( return (
@@ -50,20 +53,49 @@ export function CaseView({
onSelectContext, onSelectContext,
busy, busy,
showQuestion, showQuestion,
onRemove,
}: { }: {
c: AdvisorCase c: AdvisorCase
onSelectContext: (ctx: string) => void onSelectContext: (ctx: string) => void
busy: boolean busy: boolean
showQuestion?: boolean showQuestion?: boolean
onRemove?: () => void
}) { }) {
const r = c.response const r = c.response
const { copiedKey, copy } = useClipboard()
return ( return (
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0"> <div className="group space-y-2 border-b border-gray-100 pb-4 last:border-0">
{showQuestion && ( <div className="flex items-start justify-between gap-2">
<div className="text-xs text-gray-500"> {showQuestion ? (
<span className="font-medium text-gray-400">Frage:</span> {c.question} <div className="min-w-0 flex-1 text-xs text-gray-500">
<span className="font-medium text-gray-400">Frage:</span> {c.question}
</div>
) : (
<span className="flex-1" />
)}
<div className="flex shrink-0 items-center gap-0.5 text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
title="Frage & Antwort kopieren"
aria-label="Frage & Antwort kopieren"
onClick={() => copy(c.id, formatCaseForCopy(c))}
className="rounded p-0.5 hover:bg-gray-100 hover:text-gray-700"
>
{copiedKey === c.id ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{onRemove && (
<button
type="button"
title="Frage löschen"
aria-label="Frage löschen"
onClick={onRemove}
className="rounded p-0.5 hover:bg-gray-100 hover:text-gray-700"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
)} </div>
{c.status === 'loading' && <LoadingDots />} {c.status === 'loading' && <LoadingDots />}
{c.status === 'error' && <ErrorBox msg={c.error} />} {c.status === 'error' && <ErrorBox msg={c.error} />}
{r && r.mode === 'clarify' && ( {r && r.mode === 'clarify' && (
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef } from 'react'
import type { AdvisorCase } from './useAdvisorCase' import type { AdvisorCase, AdvisorThread } from './useAdvisorCase'
import { StickyQuestion } from './StickyQuestion' import { StickyQuestion } from './StickyQuestion'
import { AdvisorEmptyState } from './EmptyState' import { AdvisorEmptyState } from './EmptyState'
import { CaseView, LoadingDots, ErrorBox } from './CaseView' import { CaseView, LoadingDots, ErrorBox } from './CaseView'
@@ -11,36 +11,41 @@ import { EvidencePane } from './EvidencePane'
import { VisualEvidencePane } from './VisualEvidencePane' import { VisualEvidencePane } from './VisualEvidencePane'
import { FootnotesPane } from './FootnotesPane' import { FootnotesPane } from './FootnotesPane'
import { Markdown } from './Markdown' import { Markdown } from './Markdown'
import { ThreadMenu } from './ThreadMenu'
import { useCitationHighlight } from './useCitationHighlight' import { useCitationHighlight } from './useCitationHighlight'
/** /**
* Advisor body as a series of CASES. * Advisor body as topic THREADS of cases.
* - Narrow: stacked cases with a pinned last question. * - Narrow: stacked cases with a pinned last question; per-case copy + delete.
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right). * - Wide/fullscreen: 3-column Case Workspace — topic tree (left) | answer/clarify (center) | evidence (right).
*/ */
export function EvidenceWorkspace({ export function EvidenceWorkspace({
cases, cases,
threads,
expanded, expanded,
busy, busy,
activeCaseId,
exampleQuestions, exampleQuestions,
onExample, onExample,
onSelectContext, onSelectContext,
onSelectCase,
onRemove,
}: { }: {
cases: AdvisorCase[] cases: AdvisorCase[]
threads: AdvisorThread[]
expanded: boolean expanded: boolean
busy: boolean busy: boolean
activeCaseId: string | null
exampleQuestions: string[] exampleQuestions: string[]
onExample: (q: string) => void onExample: (q: string) => void
onSelectContext: (caseId: string, ctx: string) => void onSelectContext: (caseId: string, ctx: string) => void
onSelectCase: (id: string) => void
onRemove: (id: string) => void
}) { }) {
const [activeId, setActiveId] = useState<string | null>(null)
const endRef = useRef<HTMLDivElement>(null) const endRef = useRef<HTMLDivElement>(null)
const latest = cases[cases.length - 1] const latest = cases[cases.length - 1]
const active = cases.find((c) => c.id === activeId) ?? latest const active = cases.find((c) => c.id === activeCaseId) ?? latest
useEffect(() => {
setActiveId(null)
}, [cases.length])
useEffect(() => { useEffect(() => {
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' }) if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [cases.length, expanded]) }, [cases.length, expanded])
@@ -68,6 +73,7 @@ export function EvidenceWorkspace({
busy={busy} busy={busy}
showQuestion={i !== cases.length - 1} showQuestion={i !== cases.length - 1}
onSelectContext={(ctx) => onSelectContext(c.id, ctx)} onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
onRemove={() => onRemove(c.id)}
/> />
))} ))}
<div ref={endRef} /> <div ref={endRef} />
@@ -78,27 +84,19 @@ export function EvidenceWorkspace({
const r = active?.response const r = active?.response
return ( return (
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden"> <div className="grid min-h-0 flex-1 grid-cols-[260px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
<aside className="min-h-0 overflow-y-auto bg-indigo-50/40 p-3"> <aside className="min-h-0 overflow-y-auto bg-indigo-50/40 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div> <ThreadMenu
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div> threads={threads}
{answer && <EvidenceSummary response={answer} />} activeCaseId={active?.id ?? null}
{cases.length > 1 && ( onSelectCase={onSelectCase}
<div className="mt-4"> onRemove={onRemove}
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div> />
<div className="space-y-1"> {active && (
{cases.map((c) => ( <div className="mt-3 border-t border-indigo-100 pt-3">
<button <div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Aktive Frage</div>
key={c.id} <div className="mb-3 text-sm font-medium text-gray-800">{active.question}</div>
onClick={() => setActiveId(c.id)} {answer && <EvidenceSummary response={answer} />}
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
c.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{c.question}
</button>
))}
</div>
</div> </div>
)} )}
</aside> </aside>
@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import { Check, ChevronDown, ChevronRight, Copy, Files, Trash2 } from 'lucide-react'
import type { AdvisorThread } from './useAdvisorCase'
import { formatCaseForCopy, formatThreadForCopy } from '@/lib/sdk/advisor/copy'
import { useClipboard } from './useClipboard'
function IconBtn({
title,
onClick,
children,
}: {
title: string
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700"
>
{children}
</button>
)
}
/**
* Left-menu topic tree: each thread's first question is the Thema; follow-ups nest underneath and
* expand/collapse. Per row: copy (single Q&A) + delete; per topic: copy the whole thread.
*/
export function ThreadMenu({
threads,
activeCaseId,
onSelectCase,
onRemove,
}: {
threads: AdvisorThread[]
activeCaseId: string | null
onSelectCase: (id: string) => void
onRemove: (id: string) => void
}) {
const { copiedKey, copy } = useClipboard()
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const ic = 'h-3.5 w-3.5'
return (
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Themen</div>
<div className="space-y-0.5">
{threads.map((t) => {
const first = t.cases[0]
const followups = t.cases.slice(1)
const open = !collapsed[t.id]
const activeInThread = t.cases.some((c) => c.id === activeCaseId)
return (
<div key={t.id}>
<div
className={`group flex items-center gap-1 rounded px-1.5 py-1 ${
first.id === activeCaseId ? 'bg-indigo-100' : activeInThread ? 'bg-indigo-50/60' : 'hover:bg-gray-100'
}`}
>
{followups.length > 0 ? (
<button
type="button"
onClick={() => setCollapsed((s) => ({ ...s, [t.id]: !s[t.id] }))}
className="text-gray-400 hover:text-gray-700"
aria-label={open ? 'Thema einklappen' : 'Thema aufklappen'}
>
{open ? <ChevronDown className={ic} /> : <ChevronRight className={ic} />}
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<button
type="button"
onClick={() => onSelectCase(first.id)}
title={first.question}
className={`min-w-0 flex-1 truncate text-left text-[12px] font-medium ${
first.id === activeCaseId ? 'text-indigo-800' : 'text-gray-700'
}`}
>
{first.question}
</button>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<IconBtn title="Ganzes Thema kopieren" onClick={() => copy(`thread:${t.id}`, formatThreadForCopy(t.title, t.cases))}>
{copiedKey === `thread:${t.id}` ? <Check className={`${ic} text-green-600`} /> : <Files className={ic} />}
</IconBtn>
<IconBtn title="Diese Frage kopieren" onClick={() => copy(`case:${first.id}`, formatCaseForCopy(first))}>
{copiedKey === `case:${first.id}` ? <Check className={`${ic} text-green-600`} /> : <Copy className={ic} />}
</IconBtn>
<IconBtn title="Frage löschen" onClick={() => onRemove(first.id)}>
<Trash2 className={ic} />
</IconBtn>
</div>
</div>
{open &&
followups.map((c) => (
<div
key={c.id}
className={`group ml-4 flex items-center gap-1 rounded px-1.5 py-1 ${
c.id === activeCaseId ? 'bg-indigo-100' : 'hover:bg-gray-100'
}`}
>
<span className="shrink-0 text-gray-300"></span>
<button
type="button"
onClick={() => onSelectCase(c.id)}
title={c.question}
className={`min-w-0 flex-1 truncate text-left text-[11px] ${
c.id === activeCaseId ? 'text-indigo-800' : 'text-gray-600'
}`}
>
{c.question}
</button>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<IconBtn title="Diese Frage kopieren" onClick={() => copy(`case:${c.id}`, formatCaseForCopy(c))}>
{copiedKey === `case:${c.id}` ? <Check className={`${ic} text-green-600`} /> : <Copy className={ic} />}
</IconBtn>
<IconBtn title="Frage löschen" onClick={() => onRemove(c.id)}>
<Trash2 className={ic} />
</IconBtn>
</div>
</div>
))}
</div>
)
})}
</div>
</div>
)
}
@@ -1,10 +1,11 @@
'use client' 'use client'
import { useCallback, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract' import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
export interface AdvisorCase { export interface AdvisorCase {
id: string id: string
threadId: string
question: string question: string
response: AdvisorResponse | null response: AdvisorResponse | null
selectedContext: string | null selectedContext: string | null
@@ -12,34 +13,68 @@ export interface AdvisorCase {
error?: string error?: string
} }
/** A topic: the first case's question is the title; follow-ups are the rest, in order. */
export interface AdvisorThread {
id: string
title: string
cases: AdvisorCase[]
}
interface HistoryTurn {
role: 'user' | 'assistant'
content: string
}
interface UseAdvisorCaseArgs { interface UseAdvisorCaseArgs {
currentStep: string currentStep: string
country: string country: string
} }
let counter = 0
const uid = (p: string) => `${p}-${Date.now()}-${counter++}`
/** /**
* Drives the Advisor as a series of CASES. Each ask posts {question, context?} and receives a * Drives the Advisor as topic THREADS of CASES. Each ask posts {question, context?, history} and
* structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing. * receives a structured AdvisorResponse (clarify | answer) — no streaming, no answer-text parsing.
* selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer). * A follow-up appends to the active thread (and carries the thread's prior Q&A as history);
* newTopic() starts a fresh thread. selectContext() re-runs a case scoped to a chosen domain.
*/ */
export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) { export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
const [cases, setCases] = useState<AdvisorCase[]>([]) const [cases, setCases] = useState<AdvisorCase[]>([])
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [activeCaseId, setActiveCaseId] = useState<string | null>(null)
const [activeThreadId, setActiveThreadId] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
const patch = useCallback((id: string, p: Partial<AdvisorCase>) => { const patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c))) setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c)))
}, []) }, [])
// Prior answered turns of a thread, up to (but excluding) `beforeId`, for contextual follow-ups.
const buildHistory = useCallback(
(threadId: string, beforeId?: string): HistoryTurn[] => {
const turns: HistoryTurn[] = []
for (const c of cases) {
if (c.threadId !== threadId) continue
if (beforeId && c.id === beforeId) break
const a = c.response?.answer ?? c.response?.general_answer
if (!a) continue
turns.push({ role: 'user', content: c.question }, { role: 'assistant', content: a })
}
return turns
},
[cases],
)
const run = useCallback( const run = useCallback(
async (id: string, question: string, context: string | null) => { async (id: string, question: string, context: string | null, history: HistoryTurn[]) => {
setBusy(true) setBusy(true)
abortRef.current = new AbortController() abortRef.current = new AbortController()
try { try {
const res = await fetch('/api/sdk/compliance-advisor/chat', { const res = await fetch('/api/sdk/compliance-advisor/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, context, currentStep, country }), body: JSON.stringify({ question, context, history, currentStep, country }),
signal: abortRef.current.signal, signal: abortRef.current.signal,
}) })
if (!res.ok) { if (!res.ok) {
@@ -65,33 +100,89 @@ export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
) )
const ask = useCallback( const ask = useCallback(
(question: string) => { (question: string, opts?: { newThread?: boolean }) => {
const q = question.trim() const q = question.trim()
if (!q || busy) return if (!q || busy) return
const id = `case-${Date.now()}` const startNew = opts?.newThread || !activeThreadId || cases.length === 0
const threadId = startNew ? uid('thread') : activeThreadId!
const id = uid('case')
const history = startNew ? [] : buildHistory(threadId)
setCases((prev) => [ setCases((prev) => [
...prev, ...prev,
{ id, question: q, response: null, selectedContext: null, status: 'loading' }, { id, threadId, question: q, response: null, selectedContext: null, status: 'loading' },
]) ])
void run(id, q, null) setActiveThreadId(threadId)
setActiveCaseId(id)
void run(id, q, null, history)
}, },
[busy, run], [busy, activeThreadId, cases.length, buildHistory, run],
) )
const newTopic = useCallback((question: string) => ask(question, { newThread: true }), [ask])
const selectContext = useCallback( const selectContext = useCallback(
(id: string, context: string) => { (id: string, context: string) => {
const c = cases.find((x) => x.id === id) const c = cases.find((x) => x.id === id)
if (!c || busy) return if (!c || busy) return
patch(id, { status: 'loading', selectedContext: context }) patch(id, { status: 'loading', selectedContext: context })
void run(id, c.question, context) void run(id, c.question, context, buildHistory(c.threadId, id))
}, },
[cases, busy, run, patch], [cases, busy, run, patch, buildHistory],
) )
const remove = useCallback((id: string) => {
setCases((prev) => prev.filter((c) => c.id !== id))
}, [])
const selectCase = useCallback((id: string) => {
setActiveCaseId(id)
setCases((prev) => {
const c = prev.find((x) => x.id === id)
if (c) setActiveThreadId(c.threadId)
return prev
})
}, [])
const stop = useCallback(() => { const stop = useCallback(() => {
abortRef.current?.abort() abortRef.current?.abort()
setBusy(false) setBusy(false)
}, []) }, [])
return { cases, busy, ask, selectContext, stop } // Keep the active selection valid after deletions.
useEffect(() => {
if (activeCaseId && !cases.some((c) => c.id === activeCaseId)) {
const last = cases[cases.length - 1] ?? null
setActiveCaseId(last?.id ?? null)
setActiveThreadId(last?.threadId ?? null)
}
}, [cases, activeCaseId])
const threads = useMemo<AdvisorThread[]>(() => {
const order: string[] = []
const byId = new Map<string, AdvisorThread>()
for (const c of cases) {
let t = byId.get(c.threadId)
if (!t) {
t = { id: c.threadId, title: c.question, cases: [] }
byId.set(c.threadId, t)
order.push(c.threadId)
}
t.cases.push(c)
}
return order.map((id) => byId.get(id)!)
}, [cases])
return {
cases,
threads,
busy,
activeCaseId,
activeThreadId,
ask,
newTopic,
selectContext,
selectCase,
remove,
stop,
}
} }
@@ -0,0 +1,19 @@
'use client'
import { useCallback, useState } from 'react'
/** Writes text to the clipboard and flags the copied key briefly (for a check-mark affordance). */
export function useClipboard(resetMs = 1500) {
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const copy = useCallback(
(key: string, text: string) => {
void navigator.clipboard?.writeText(text)
setCopiedKey(key)
window.setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), resetMs)
},
[resetMs],
)
return { copiedKey, copy }
}
@@ -0,0 +1,70 @@
/**
* E2E: Compliance Advisor widget UX — topic threads, new-topic vs follow-up, delete, copy, fullscreen.
* Stubs the chat endpoint with an answer fixture so every ask yields a finished case.
*/
import { test, expect } from '../fixtures/sdk-fixtures'
const CHAT_ROUTE = '**/api/sdk/compliance-advisor/chat'
const openAdvisor = 'Compliance Advisor oeffnen'
const ANSWER = {
mode: 'answer',
question: '',
clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.9 },
general_answer: null,
answer: 'Musterantwort [1].',
scoped_query: null,
evidence: [{ evidence_id: 'e1', document: 'DSGVO', section: 'Art. 5', bindingness: 'binding' }],
citations: [{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'DSGVO', section: 'Art. 5' }],
visual_evidence: [],
footnotes: [],
}
async function stub(page: import('@playwright/test').Page) {
await page.route(CHAT_ROUTE, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ANSWER) }),
)
}
test('new topic creates a second thread; copy control + fullscreen available', async ({ sdkPage }) => {
await stub(sdkPage)
await sdkPage.getByRole('button', { name: openAdvisor }).click()
const input = sdkPage.getByPlaceholder('Frage eingeben...')
await input.fill('Erste Frage')
await input.press('Enter')
await expect(sdkPage.getByText(/Musterantwort/)).toBeVisible()
// expand -> the topic tree ("Themen") appears in the left menu
await sdkPage.getByRole('button', { name: 'Vergroessern' }).click()
await expect(sdkPage.getByText('Themen')).toBeVisible()
await expect(sdkPage.getByText('Erste Frage').first()).toBeVisible()
// a second, separate topic
await sdkPage.getByPlaceholder('Folgefrage eingeben...').fill('Zweites Thema')
await sdkPage.getByRole('button', { name: 'Neues Thema' }).click()
await expect(sdkPage.getByText('Zweites Thema').first()).toBeVisible()
await expect(sdkPage.getByText('Erste Frage').first()).toBeVisible()
// copy affordance + fullscreen toggle
await expect(sdkPage.getByRole('button', { name: 'Diese Frage kopieren' }).first()).toBeVisible()
await sdkPage.getByRole('button', { name: 'Vollbild' }).click()
await expect(sdkPage.getByRole('button', { name: 'Vollbild verlassen' })).toBeVisible()
})
test('delete removes a question from the thread', async ({ sdkPage }) => {
await stub(sdkPage)
await sdkPage.getByRole('button', { name: openAdvisor }).click()
const input = sdkPage.getByPlaceholder('Frage eingeben...')
await input.fill('Zu löschen')
await input.press('Enter')
await expect(sdkPage.getByText(/Musterantwort/)).toBeVisible()
await sdkPage.getByRole('button', { name: 'Vergroessern' }).click()
await expect(sdkPage.getByText('Zu löschen').first()).toBeVisible()
await sdkPage.getByRole('button', { name: 'Frage löschen' }).first().click()
await expect(sdkPage.getByText('Zu löschen')).toHaveCount(0)
})
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { formatCaseForCopy, formatThreadForCopy } from '../advisor/copy'
import type { AdvisorResponse } from '../advisor/contract'
const answer: AdvisorResponse = {
mode: 'answer',
question: 'CRA Meldefrist',
clarity: { is_underspecified: false, concentration: 0.9 },
general_answer: null,
answer: 'Unverzüglich melden [1].',
scoped_query: 'cyber',
evidence: [{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', url: 'https://x' }],
citations: [],
visual_evidence: [],
footnotes: [{ footnote_id: 'f1', ref: 'Fußnote 3', document: 'EDPB', section: 'Kap III', text: 'Detail' }],
}
const clarify: AdvisorResponse = {
mode: 'clarify',
question: 'Was ist PDCA?',
clarity: { is_underspecified: true, concentration: 0.3 },
general_answer: 'PDCA = Plan-Do-Check-Act.',
answer: null,
scoped_query: null,
evidence: [],
citations: [],
visual_evidence: [],
footnotes: [],
}
describe('formatCaseForCopy', () => {
it('includes question, answer, resolved evidence and footnotes', () => {
const s = formatCaseForCopy({ question: 'CRA Meldefrist', response: answer })
expect(s).toContain('### Frage\nCRA Meldefrist')
expect(s).toContain('### Antwort\nUnverzüglich melden [1].')
expect(s).toContain('### Belege')
expect(s).toContain('Cyber Resilience Act (CRA), Art. 14 Abs. 1 (https://x)')
expect(s).toContain('### Fußnoten')
expect(s).toContain('Fußnote 3 — EDPB / Kap III: Detail')
})
it('falls back to the general definition for a clarify case', () => {
const s = formatCaseForCopy({ question: 'Was ist PDCA?', response: clarify })
expect(s).toContain('### Antwort\nPDCA = Plan-Do-Check-Act.')
expect(s).not.toContain('### Belege')
})
it('handles a case without a response', () => {
const s = formatCaseForCopy({ question: 'offen', response: null })
expect(s).toContain('### Antwort\n(keine Antwort)')
})
})
describe('formatThreadForCopy', () => {
it('renders a title heading + every case separated by a rule', () => {
const s = formatThreadForCopy('CRA Meldefrist', [
{ question: 'CRA Meldefrist', response: answer },
{ question: 'Und für KMU?', response: clarify },
])
expect(s.startsWith('# CRA Meldefrist')).toBe(true)
expect(s).toContain('\n---\n')
expect(s).toContain('CRA Meldefrist')
expect(s).toContain('Und für KMU?')
})
})
+48
View File
@@ -0,0 +1,48 @@
// Pure formatters for "copy to clipboard": a single case (Q + answer + evidence) or a whole thread.
// No DOM/clipboard here (that is the caller's side effect) so this stays testable.
import type { AdvisorResponse, EvidenceUnit } from './contract'
import { resolveRegulation } from './regulation-display'
export interface CopyableCase {
question: string
response: AdvisorResponse | null
}
function evidenceLine(e: EvidenceUnit): string {
const { familyLabel } = resolveRegulation({ code: e.regulation_code || e.document, short: e.document })
const loc = [e.section, e.paragraph].filter(Boolean).join(' ')
const ref = [familyLabel, loc].filter(Boolean).join(', ')
return e.url ? `- ${ref} (${e.url})` : `- ${ref}`
}
/** One case as portable Markdown: question, answer (or general definition), and its evidence. */
export function formatCaseForCopy(c: CopyableCase): string {
const parts: string[] = [`### Frage`, c.question.trim()]
const r = c.response
const answer = (r?.answer || r?.general_answer || '').trim()
parts.push('', '### Antwort', answer || '(keine Antwort)')
if (r && r.evidence.length > 0) {
parts.push('', '### Belege', ...r.evidence.map(evidenceLine))
}
if (r && r.footnotes.length > 0) {
parts.push(
'',
'### Fußnoten',
...r.footnotes.map((f, i) => {
const head = f.ref || `Fußnote ${i + 1}`
const src = [f.document, f.section].filter(Boolean).join(' / ')
return `- ${[head, src].filter(Boolean).join(' — ')}${f.text ? `: ${f.text}` : ''}`
}),
)
}
return parts.join('\n')
}
/** A whole topic thread: a title heading followed by every case, separated by rules. */
export function formatThreadForCopy(title: string, cases: CopyableCase[]): string {
const header = `# ${title.trim() || 'Compliance-Advisor-Verlauf'}`
const body = cases.map(formatCaseForCopy).join('\n\n---\n\n')
return `${header}\n\n${body}`
}