Files
breakpilot-compliance/admin-compliance/components/sdk/advisor/useAdvisorCase.ts
T
Benjamin Admin e8ea179228 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>
2026-07-01 18:51:17 +02:00

189 lines
5.9 KiB
TypeScript

'use client'
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
status: 'loading' | 'done' | 'error'
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 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, 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, history, currentStep, country }),
signal: abortRef.current.signal,
})
if (!res.ok) {
const e = await res.json().catch(() => ({ error: 'Unbekannter Fehler' }))
throw new Error(e.error || `Server-Fehler (${res.status})`)
}
const data = (await res.json()) as AdvisorResponse
patch(id, { response: data, status: 'done', selectedContext: context })
} catch (err) {
if ((err as Error).name === 'AbortError') {
patch(id, { status: 'done' })
return
}
patch(id, {
status: 'error',
error: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen',
})
} finally {
setBusy(false)
}
},
[currentStep, country, patch],
)
const ask = useCallback(
(question: string, opts?: { newThread?: boolean }) => {
const q = question.trim()
if (!q || busy) return
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, threadId, question: q, response: null, selectedContext: null, status: 'loading' },
])
setActiveThreadId(threadId)
setActiveCaseId(id)
void run(id, q, null, history)
},
[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, buildHistory(c.threadId, id))
},
[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)
}, [])
// 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,
}
}