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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user