feat(advisor): topic threads, per-question delete/copy, fullscreen
Adds case management to the Compliance Advisor widget. - topic threads: cases group into threads; the left menu shows each thread's first question as the Thema with expandable follow-ups. Send = follow-up to the active thread (carries the thread's prior Q&A as history for contextual answers); "+" starts a new topic. - delete: a trash action per question (menu + stacked view). - copy: single Q&A (question + answer + evidence + footnotes) or a whole thread, as Markdown to the clipboard (pure formatters in copy.ts). - fullscreen: compact -> panel -> fullscreen view. - route.ts consumes an optional bounded `history` so follow-ups are contextual for both the widget and the workspace consumer. Tests: copy formatter unit tests + Playwright specs (threads/new-topic, delete, fullscreen, copy affordance). No deploy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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?')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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