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
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:
@@ -75,12 +75,28 @@ function answerSystem(
|
||||
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) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const question = String(body.question ?? body.message ?? '').trim()
|
||||
const context: string | null = body.context ?? null
|
||||
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)
|
||||
? (body.country as Country)
|
||||
: undefined
|
||||
@@ -98,6 +114,7 @@ export async function POST(request: NextRequest) {
|
||||
const legacySoul = await readSoulFile('compliance-advisor')
|
||||
const legacyStream = await streamAdvisorAnswer([
|
||||
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) },
|
||||
...history,
|
||||
{ role: 'user', content: question },
|
||||
])
|
||||
if (!legacyStream) {
|
||||
@@ -141,6 +158,7 @@ export async function POST(request: NextRequest) {
|
||||
const soul = await readSoulFile('compliance-advisor')
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) },
|
||||
...history,
|
||||
{ role: 'user', content: question },
|
||||
]
|
||||
const answer = await completeAdvisorAnswer(messages)
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
'use client'
|
||||
|
||||
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 { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
|
||||
import { useAdvisorCase } from './advisor/useAdvisorCase'
|
||||
@@ -14,20 +27,29 @@ interface ComplianceAdvisorWidgetProps {
|
||||
type 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.
|
||||
* See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract.
|
||||
*/
|
||||
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [view, setView] = useState<View>('compact')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
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 exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
|
||||
const expanded = view !== 'compact'
|
||||
|
||||
const submit = useCallback(
|
||||
(q: string) => {
|
||||
@@ -38,6 +60,15 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
[busy, ask],
|
||||
)
|
||||
|
||||
const submitNewTopic = useCallback(
|
||||
(q: string) => {
|
||||
if (!q.trim() || busy) return
|
||||
setInputValue('')
|
||||
newTopic(q)
|
||||
},
|
||||
[busy, newTopic],
|
||||
)
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
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 (
|
||||
<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-[960px]' : 'h-[560px] w-[420px]'
|
||||
}`}
|
||||
className={`fixed z-50 flex max-h-screen flex-col border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${SIZE[view]}`}
|
||||
>
|
||||
<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 h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||
<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" />}
|
||||
</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
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
onClick={() => setView((v) => (v === 'fullscreen' ? 'panel' : 'fullscreen'))}
|
||||
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
|
||||
onClick={() => setIsOpen(false)}
|
||||
@@ -116,21 +158,25 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
|
||||
<EvidenceWorkspace
|
||||
cases={cases}
|
||||
expanded={isExpanded}
|
||||
threads={threads}
|
||||
expanded={expanded}
|
||||
busy={busy}
|
||||
activeCaseId={activeCaseId}
|
||||
exampleQuestions={exampleQuestions}
|
||||
onExample={submit}
|
||||
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">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Frage eingeben..."
|
||||
placeholder={cases.length > 0 ? 'Folgefrage eingeben...' : 'Frage eingeben...'}
|
||||
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"
|
||||
/>
|
||||
@@ -139,13 +185,27 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
<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>
|
||||
<>
|
||||
{cases.length > 0 && (
|
||||
<button
|
||||
onClick={() => submitNewTopic(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
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"
|
||||
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>
|
||||
|
||||
@@ -41,7 +41,7 @@ const answer: AdvisorResponse = {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { Check, Copy, Trash2 } from 'lucide-react'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
import { formatCaseForCopy } from '@/lib/sdk/advisor/copy'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
import { ClarifyView } from './ClarifyView'
|
||||
import { EvidenceSummary } from './EvidenceSummary'
|
||||
@@ -9,6 +11,7 @@ import { VisualEvidencePane } from './VisualEvidencePane'
|
||||
import { FootnotesPane } from './FootnotesPane'
|
||||
import { Markdown } from './Markdown'
|
||||
import { useCitationHighlight } from './useCitationHighlight'
|
||||
import { useClipboard } from './useClipboard'
|
||||
|
||||
export function LoadingDots() {
|
||||
return (
|
||||
@@ -50,20 +53,49 @@ export function CaseView({
|
||||
onSelectContext,
|
||||
busy,
|
||||
showQuestion,
|
||||
onRemove,
|
||||
}: {
|
||||
c: AdvisorCase
|
||||
onSelectContext: (ctx: string) => void
|
||||
busy: boolean
|
||||
showQuestion?: boolean
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
const r = c.response
|
||||
const { copiedKey, copy } = useClipboard()
|
||||
return (
|
||||
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
|
||||
{showQuestion && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-400">Frage:</span> {c.question}
|
||||
<div className="group space-y-2 border-b border-gray-100 pb-4 last:border-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{showQuestion ? (
|
||||
<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>
|
||||
{c.status === 'loading' && <LoadingDots />}
|
||||
{c.status === 'error' && <ErrorBox msg={c.error} />}
|
||||
{r && r.mode === 'clarify' && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { AdvisorCase, AdvisorThread } from './useAdvisorCase'
|
||||
import { StickyQuestion } from './StickyQuestion'
|
||||
import { AdvisorEmptyState } from './EmptyState'
|
||||
import { CaseView, LoadingDots, ErrorBox } from './CaseView'
|
||||
@@ -11,36 +11,41 @@ import { EvidencePane } from './EvidencePane'
|
||||
import { VisualEvidencePane } from './VisualEvidencePane'
|
||||
import { FootnotesPane } from './FootnotesPane'
|
||||
import { Markdown } from './Markdown'
|
||||
import { ThreadMenu } from './ThreadMenu'
|
||||
import { useCitationHighlight } from './useCitationHighlight'
|
||||
|
||||
/**
|
||||
* Advisor body as a series of CASES.
|
||||
* - Narrow: stacked cases with a pinned last question.
|
||||
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
|
||||
* Advisor body as topic THREADS of cases.
|
||||
* - Narrow: stacked cases with a pinned last question; per-case copy + delete.
|
||||
* - Wide/fullscreen: 3-column Case Workspace — topic tree (left) | answer/clarify (center) | evidence (right).
|
||||
*/
|
||||
export function EvidenceWorkspace({
|
||||
cases,
|
||||
threads,
|
||||
expanded,
|
||||
busy,
|
||||
activeCaseId,
|
||||
exampleQuestions,
|
||||
onExample,
|
||||
onSelectContext,
|
||||
onSelectCase,
|
||||
onRemove,
|
||||
}: {
|
||||
cases: AdvisorCase[]
|
||||
threads: AdvisorThread[]
|
||||
expanded: boolean
|
||||
busy: boolean
|
||||
activeCaseId: string | null
|
||||
exampleQuestions: string[]
|
||||
onExample: (q: 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 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(() => {
|
||||
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [cases.length, expanded])
|
||||
@@ -68,6 +73,7 @@ export function EvidenceWorkspace({
|
||||
busy={busy}
|
||||
showQuestion={i !== cases.length - 1}
|
||||
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
|
||||
onRemove={() => onRemove(c.id)}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
@@ -78,27 +84,19 @@ export function EvidenceWorkspace({
|
||||
|
||||
const r = active?.response
|
||||
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">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div>
|
||||
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
|
||||
{answer && <EvidenceSummary response={answer} />}
|
||||
{cases.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div>
|
||||
<div className="space-y-1">
|
||||
{cases.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveId(c.id)}
|
||||
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>
|
||||
<ThreadMenu
|
||||
threads={threads}
|
||||
activeCaseId={active?.id ?? null}
|
||||
onSelectCase={onSelectCase}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
{active && (
|
||||
<div className="mt-3 border-t border-indigo-100 pt-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Aktive Frage</div>
|
||||
<div className="mb-3 text-sm font-medium text-gray-800">{active.question}</div>
|
||||
{answer && <EvidenceSummary response={answer} />}
|
||||
</div>
|
||||
)}
|
||||
</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'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
|
||||
export interface AdvisorCase {
|
||||
id: string
|
||||
threadId: string
|
||||
question: string
|
||||
response: AdvisorResponse | null
|
||||
selectedContext: string | null
|
||||
@@ -12,34 +13,68 @@ export interface AdvisorCase {
|
||||
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 {
|
||||
currentStep: 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
|
||||
* structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing.
|
||||
* selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer).
|
||||
* Drives the Advisor as topic THREADS of CASES. Each ask posts {question, context?, history} and
|
||||
* receives a structured AdvisorResponse (clarify | answer) — no streaming, no answer-text parsing.
|
||||
* 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) {
|
||||
const [cases, setCases] = useState<AdvisorCase[]>([])
|
||||
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 patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
|
||||
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(
|
||||
async (id: string, question: string, context: string | null) => {
|
||||
async (id: string, question: string, context: string | null, history: HistoryTurn[]) => {
|
||||
setBusy(true)
|
||||
abortRef.current = new AbortController()
|
||||
try {
|
||||
const res = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question, context, currentStep, country }),
|
||||
body: JSON.stringify({ question, context, history, currentStep, country }),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -65,33 +100,89 @@ export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
|
||||
)
|
||||
|
||||
const ask = useCallback(
|
||||
(question: string) => {
|
||||
(question: string, opts?: { newThread?: boolean }) => {
|
||||
const q = question.trim()
|
||||
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) => [
|
||||
...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(
|
||||
(id: string, context: string) => {
|
||||
const c = cases.find((x) => x.id === id)
|
||||
if (!c || busy) return
|
||||
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(() => {
|
||||
abortRef.current?.abort()
|
||||
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?')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user