// AssistInterview — the conversational AI step of a declarative wizard // (`form_layout.sections[].assist`, kernel v0.141.0). // // Link-onboarding style: ONE question at a time as a big typed headline, // progress beats on top, a single answer input (or quick-reply chips) at the // bottom, and the assistant's work (fetching a website, thinking…) shown as // live status. No form inputs: the provider asks for whatever it needs, does // the work, previews what it found and returns the fields the form takes. // The panel is generic — it renders the turns the provider sends and knows // nothing about brands. Protocol (host `/assist/:provider/…`): // // POST /assist/:provider/sessions { input } → Session // GET /assist/:provider/sessions/:id → Session (poll while working) // POST /assist/:provider/sessions/:id/reply { key, value } → Session import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Input } from '@asteby/metacore-ui/primitives' import { ArrowRight, Check, CheckCircle2, Loader2, RotateCcw, Sparkles, XCircle } from 'lucide-react' import { useApi } from './api-context' import type { FormAssist } from './form-layout' export interface AssistProgressStep { label: string state: 'pending' | 'running' | 'done' | 'error' detail?: string } export interface AssistQuestion { key: string prompt: string kind?: 'text' | 'choice' | 'confirm' options?: { value: string; label: string }[] placeholder?: string /** Prefilled suggestion the user can accept with Enter. */ suggestion?: string } export interface AssistCard { kind: string title?: string logo?: string logo_light?: string logo_dark?: string colors?: Record lines?: { label: string; value: string }[] } export interface AssistTurn { id: string type: 'message' | 'progress' | 'card' | 'question' | 'answer' | 'result' text?: string steps?: AssistProgressStep[] card?: AssistCard question?: AssistQuestion fields?: Record } export interface AssistSession { id: string working: boolean done: boolean turns: AssistTurn[] result?: Record error?: string /** Optional: how many beats the provider expects (progress dots). */ beats?: number } export interface AssistInterviewProps { assist: FormAssist /** Live form values; the declared `input` fields are sent on start. */ values: Record /** Called with the provider's result fields (limited to `output`). */ onApply: (fields: Record) => void /** Start immediately (the step is the chat itself). Default true. */ autoStart?: boolean /** Small uppercase label above the headline (e.g. the step title). */ eyebrow?: string className?: string } const POLL_MS = 800 const THINKING_HINTS = ['Un momento…', 'Sigo trabajando…', 'Casi listo…'] function unwrap(res: any): AssistSession { const d = res?.data?.data ?? res?.data ?? res return d as AssistSession } function useTypewriter(text: string) { const [n, setN] = useState(0) const reduce = typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches useEffect(() => { setN(reduce ? text.length : 0) }, [text, reduce]) useEffect(() => { if (n >= text.length) return const t = setTimeout(() => setN(v => Math.min(text.length, v + 3)), 22) return () => clearTimeout(t) }, [n, text]) return { shown: text.slice(0, n), typing: n < text.length } } export function AssistInterview({ assist, values, onApply, autoStart = true, eyebrow, className }: AssistInterviewProps) { const api = useApi() const [session, setSession] = useState(null) const [starting, setStarting] = useState(false) const [answer, setAnswer] = useState('') const [applied, setApplied] = useState(false) const [error, setError] = useState(null) const [hintIdx, setHintIdx] = useState(0) const inputRef = useRef(null) // Answers given so far: if the host loses the session (restart, TTL) we // start a new one and replay them, so the user never types twice. const answersRef = useRef<{ key: string; value: any }[]>([]) const base = `/assist/${encodeURIComponent(assist.provider)}/sessions` const inputPayload = useMemo(() => { const out: Record = {} for (const k of assist.input ?? []) out[k] = values?.[k] return out }, [assist.input, values]) const start = useCallback(async () => { if (starting) return setStarting(true) setError(null) setApplied(false) answersRef.current = [] try { const res = await api.post(base, { input: inputPayload }) setSession(unwrap(res)) } catch (e: any) { setError(e?.response?.data?.message || e?.message || 'No se pudo iniciar el asistente') } finally { setStarting(false) } }, [api, base, inputPayload, starting]) const autoFired = useRef(false) useEffect(() => { if (!autoStart || autoFired.current || session) return autoFired.current = true void start() }, [autoStart, session, start]) // Poll while the provider works. useEffect(() => { if (!session || !session.working || session.done) return const t = setInterval(async () => { try { const res = await api.get(`${base}/${session.id}`) setSession(unwrap(res)) } catch (e: any) { if (isGone(e)) { try { setSession(await recover()) } catch { /* next tick retries */ } } } }, POLL_MS) return () => clearInterval(t) }, [api, base, session]) // Rotate the thinking hint while working. useEffect(() => { if (!session?.working) return setHintIdx(0) const t = setInterval(() => setHintIdx(i => Math.min(i + 1, THINKING_HINTS.length - 1)), 2600) return () => clearInterval(t) }, [session?.working]) // Apply the result once. useEffect(() => { if (!session?.done || !session.result || applied) return const allowed = assist.output?.length ? new Set(assist.output) : null const out: Record = {} for (const [k, v] of Object.entries(session.result)) { if (allowed && !allowed.has(k)) continue if (v === undefined || v === null || v === '') continue out[k] = v } onApply(out) setApplied(true) }, [session, applied, assist.output, onApply]) const isGone = (e: any) => e?.response?.status === 404 || /sesi[oó]n no encontrada/i.test(String(e?.response?.data?.message || e?.message || '')) // Recreate the session and replay every answer (host lost it). const recover = useCallback(async () => { const res = await api.post(base, { input: inputPayload }) let s = unwrap(res) for (const a of answersRef.current) { // wait until the provider is ready for the next answer for (let i = 0; i < 120 && s.working && !s.done; i++) { await new Promise(r => setTimeout(r, POLL_MS)) s = unwrap(await api.get(`${base}/${s.id}`)) } if (s.done) break s = unwrap(await api.post(`${base}/${s.id}/reply`, { key: a.key, value: a.value })) } return s }, [api, base, inputPayload]) const reply = useCallback( async (key: string, value: any) => { if (!session) return setAnswer('') setError(null) setSession(s => (s ? { ...s, working: true } : s)) try { const res = await api.post(`${base}/${session.id}/reply`, { key, value }) answersRef.current = [...answersRef.current, { key, value }] setSession(unwrap(res)) } catch (e: any) { if (isGone(e)) { try { answersRef.current = [...answersRef.current, { key, value }] setSession(await recover()) return } catch (e2: any) { setError(e2?.response?.data?.message || e2?.message || 'No se pudo retomar la conversación') } } else { setError(e?.response?.data?.message || e?.message || 'No se pudo enviar la respuesta') } setSession(s => (s ? { ...s, working: false } : s)) } }, [api, base, session, recover], ) const turns = session?.turns ?? [] const last = turns[turns.length - 1] const pendingQuestion = !session?.working && !session?.done && last?.type === 'question' ? last.question : null const lastSpoken = [...turns].reverse().find(t => t.type === 'message' || t.type === 'question') const headline = lastSpoken?.type === 'question' ? lastSpoken.question?.prompt ?? '' : lastSpoken?.text ?? '' const { shown, typing } = useTypewriter(headline || (starting ? 'Un segundo…' : '')) // The latest progress block stays visible as compact chips (done / error // included) until the next block replaces it; the card that followed it // shows whenever the assistant is waiting on the user or finished. const activeProgress = [...turns].reverse().find(t => t.type === 'progress') const lastCard = [...turns].reverse().find(t => t.type === 'card')?.card const answered = turns.filter(t => t.type === 'answer').length const beats = Math.max(session?.beats ?? 5, 2) const progress = session?.done ? beats : Math.min(answered + 1, beats) const running = activeProgress?.steps?.find(s => s.state === 'running') useEffect(() => { if (pendingQuestion && !typing) inputRef.current?.focus() }, [pendingQuestion, typing]) const submitText = () => { if (!pendingQuestion) return const v = answer.trim() || pendingQuestion.suggestion || '' if (v) void reply(pendingQuestion.key, v) } return (
{/* top: eyebrow + beats */}

{eyebrow || assist.label || 'Asistente'}

{Array.from({ length: beats }).map((_, i) => ( ))}
{/* the question — one at a time, typed out */}

{shown} {typing && }

{/* status line */}
{(session?.working || starting) && !running && ( {THINKING_HINTS[hintIdx]} )} {activeProgress && (
    {(activeProgress.steps ?? []).map((s, i) => (
  • {s.state === 'done' ? : s.state === 'running' ? : s.state === 'error' ? : } {s.label} {s.state === 'running' && s.detail && · {s.detail}}
  • ))}
)} {lastCard && !session?.working && } {session?.done && ( {applied ? 'Listo. Pulsa Siguiente para revisar lo que armé.' : 'Listo.'} )} {(error || session?.error) &&

{error || session?.error}

}
{/* the answer */} {pendingQuestion && (
{pendingQuestion.kind === 'choice' || pendingQuestion.kind === 'confirm' ? (
{(pendingQuestion.options ?? [{ value: 'yes', label: 'Sí' }, { value: 'no', label: 'No' }]).map(o => ( ))}
) : (
setAnswer(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault() submitText() } }} placeholder={pendingQuestion.suggestion || pendingQuestion.placeholder || 'Escribe tu respuesta…'} className="h-12 pr-12 text-base" autoFocus />
)} {pendingQuestion.suggestion && pendingQuestion.kind !== 'choice' && (

Enter acepta la sugerencia.

)}
)} {/* idle / retry */} {!session && !starting && (
)} {session?.done && (
)}
) } /** Preview card: logo on dark and light, colour swatches, key/value lines. */ export function AssistCardView({ card }: { card: AssistCard }) { const colors = Object.entries(card.colors ?? {}) const logoDark = card.logo_light || card.logo const logoLight = card.logo_dark || card.logo return (
{card.title &&

{card.title}

} {(card.logo || card.logo_light || card.logo_dark) && (
{logoDark && }
{logoLight && }
)} {colors.length > 0 && (
{colors.map(([k, v]) => ( {k} {v} ))}
)} {(card.lines ?? []).length > 0 && (
{card.lines!.map(l => (
{l.label}
{l.value}
))}
)}
) }