e8ea179228
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>
189 lines
5.9 KiB
TypeScript
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,
|
|
}
|
|
}
|