'use client'; import { useEffect, useRef, useState } from 'react'; import { ChevronDown, Loader2, Send, Sparkles } from 'lucide-react'; import { cn } from '@/lib/format'; import { Markdown } from './markdown'; interface ToolTrace { id: string; name: string; input: unknown; ok?: boolean; ms?: number; summary?: string; preview?: unknown; } interface Turn { id: string; role: 'user' | 'assistant'; content: string; tools: ToolTrace[]; streaming?: boolean; error?: string; usdEst?: number; } export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiReady: boolean; model: string | null; suggestions: string[]; prefill: string }) { const [sessionId, setSessionId] = useState(null); const [turns, setTurns] = useState([]); const [input, setInput] = useState(prefill); const [busy, setBusy] = useState(false); const [sessions, setSessions] = useState>([]); const bottom = useRef(null); const abort = useRef(null); useEffect(() => { fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {}); }, []); useEffect(() => { bottom.current?.scrollIntoView({ block: 'end' }); }, [turns]); async function openSession(id: string) { const r = await fetch(`/api/research/${id}`); if (!r.ok) return; const j = (await r.json()) as { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; toolCalls: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> }> }; setSessionId(id); setTurns(j.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, tools: (m.toolCalls ?? []).map((t, i) => ({ id: `${m.id}-${i}`, ...t })) }))); } async function send(text: string) { const message = text.trim(); if (!message || busy) return; setInput(''); setBusy(true); const userTurn: Turn = { id: `u-${Date.now()}`, role: 'user', content: message, tools: [] }; const asst: Turn = { id: `a-${Date.now()}`, role: 'assistant', content: '', tools: [], streaming: true }; setTurns((t) => [...t, userTurn, asst]); const update = (fn: (a: Turn) => Turn) => setTurns((t) => t.map((x) => (x.id === asst.id ? fn(x) : x))); abort.current = new AbortController(); try { const res = await fetch('/api/research', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, message }), signal: abort.current.signal }); if (!res.ok || !res.body) { const j = await res.json().catch(() => ({})); throw new Error((j as { error?: string }).error ?? `HTTP ${res.status}`); } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop() ?? ''; for (const p of parts) { const line = p.replace(/^data: /, ''); if (line === '[DONE]') continue; let ev: Record; try { ev = JSON.parse(line); } catch { continue; } switch (ev.type) { case 'session': setSessionId(ev.sessionId as string); break; case 'text': update((a) => ({ ...a, content: a.content + (ev.text as string) })); break; case 'tool_call': update((a) => ({ ...a, tools: [...a.tools, { id: ev.id as string, name: ev.name as string, input: ev.input }] })); break; case 'tool_result': update((a) => ({ ...a, tools: a.tools.map((t) => (t.id === ev.id ? { ...t, ok: ev.ok as boolean, ms: ev.ms as number, summary: ev.summary as string, preview: ev.preview } : t)) })); break; case 'error': update((a) => ({ ...a, error: ev.message as string })); break; case 'done': update((a) => ({ ...a, streaming: false, usdEst: ev.usdEst as number })); break; } } } } catch (err) { update((a) => ({ ...a, streaming: false, error: err instanceof Error ? err.message : String(err) })); } finally { setBusy(false); update((a) => ({ ...a, streaming: false })); fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {}); } } return (
{aiReady ? `Grounded research · ${model}` : 'AI provider not configured'} Tool calls are shown inline
{turns.length === 0 ? (

Try one of these:

{suggestions.map((s) => ( ))}
) : null} {turns.map((t) => (
{t.tools.length ? (
    {t.tools.map((tool) => ( ))}
) : null} {t.role === 'assistant' ? :

{t.content}

} {t.streaming && !t.content ? : null} {t.error ?

{t.error}

: null} {t.role === 'assistant' && !t.streaming && t.usdEst !== undefined ?

Grounded in {t.tools.length} quer{t.tools.length === 1 ? 'y' : 'ies'} · est. cost ${t.usdEst.toFixed(4)}

: null}
))}
{ e.preventDefault(); void send(input); }} >