TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import { useEffect, useRef, useState } from 'react';4import { ChevronDown, Loader2, Send, Sparkles } from 'lucide-react';5import { cn } from '@/lib/format';6import { Markdown } from './markdown';78interface ToolTrace {9 id: string;10 name: string;11 input: unknown;12 ok?: boolean;13 ms?: number;14 summary?: string;15 preview?: unknown;16}17interface Turn {18 id: string;19 role: 'user' | 'assistant';20 content: string;21 tools: ToolTrace[];22 streaming?: boolean;23 error?: string;24 usdEst?: number;25}2627export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiReady: boolean; model: string | null; suggestions: string[]; prefill: string }) {28 const [sessionId, setSessionId] = useState<string | null>(null);29 const [turns, setTurns] = useState<Turn[]>([]);30 const [input, setInput] = useState(prefill);31 const [busy, setBusy] = useState(false);32 const [sessions, setSessions] = useState<Array<{ id: string; title: string | null; updatedAt: string }>>([]);33 const bottom = useRef<HTMLDivElement>(null);34 const abort = useRef<AbortController | null>(null);3536 useEffect(() => {37 fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {});38 }, []);39 useEffect(() => {40 bottom.current?.scrollIntoView({ block: 'end' });41 }, [turns]);4243 async function openSession(id: string) {44 const r = await fetch(`/api/research/${id}`);45 if (!r.ok) return;46 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 }> }> };47 setSessionId(id);48 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 })) })));49 }5051 async function send(text: string) {52 const message = text.trim();53 if (!message || busy) return;54 setInput('');55 setBusy(true);56 const userTurn: Turn = { id: `u-${Date.now()}`, role: 'user', content: message, tools: [] };57 const asst: Turn = { id: `a-${Date.now()}`, role: 'assistant', content: '', tools: [], streaming: true };58 setTurns((t) => [...t, userTurn, asst]);59 const update = (fn: (a: Turn) => Turn) => setTurns((t) => t.map((x) => (x.id === asst.id ? fn(x) : x)));60 abort.current = new AbortController();61 try {62 const res = await fetch('/api/research', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, message }), signal: abort.current.signal });63 if (!res.ok || !res.body) {64 const j = await res.json().catch(() => ({}));65 throw new Error((j as { error?: string }).error ?? `HTTP ${res.status}`);66 }67 const reader = res.body.getReader();68 const dec = new TextDecoder();69 let buf = '';70 for (;;) {71 const { value, done } = await reader.read();72 if (done) break;73 buf += dec.decode(value, { stream: true });74 const parts = buf.split('\n\n');75 buf = parts.pop() ?? '';76 for (const p of parts) {77 const line = p.replace(/^data: /, '');78 if (line === '[DONE]') continue;79 let ev: Record<string, unknown>;80 try {81 ev = JSON.parse(line);82 } catch {83 continue;84 }85 switch (ev.type) {86 case 'session':87 setSessionId(ev.sessionId as string);88 break;89 case 'text':90 update((a) => ({ ...a, content: a.content + (ev.text as string) }));91 break;92 case 'tool_call':93 update((a) => ({ ...a, tools: [...a.tools, { id: ev.id as string, name: ev.name as string, input: ev.input }] }));94 break;95 case 'tool_result':96 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)) }));97 break;98 case 'error':99 update((a) => ({ ...a, error: ev.message as string }));100 break;101 case 'done':102 update((a) => ({ ...a, streaming: false, usdEst: ev.usdEst as number }));103 break;104 }105 }106 }107 } catch (err) {108 update((a) => ({ ...a, streaming: false, error: err instanceof Error ? err.message : String(err) }));109 } finally {110 setBusy(false);111 update((a) => ({ ...a, streaming: false }));112 fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {});113 }114 }115116 return (117 <div className="grid gap-4 lg:grid-cols-[220px_1fr]">118 <aside className="hidden lg:block">119 <button type="button" onClick={() => { setSessionId(null); setTurns([]); }} className="mb-2 w-full rounded-md border border-border px-3 py-1.5 text-left text-xs font-medium hover:bg-inset">+ New thread</button>120 <ul className="space-y-0.5">121 {sessions.map((s) => (122 <li key={s.id}>123 <button type="button" onClick={() => void openSession(s.id)} className={cn('w-full truncate rounded-md px-2 py-1.5 text-left text-xs', s.id === sessionId ? 'bg-inset text-fg' : 'text-muted hover:bg-inset hover:text-fg')} title={s.title ?? ''}>124 {s.title ?? 'Untitled'}125 </button>126 </li>127 ))}128 </ul>129 </aside>130 <section className="card flex h-[calc(100dvh-var(--ri-header-h,52px)-var(--ri-tabbar-h,56px)-7rem)] min-h-[420px] flex-col md:h-[calc(100dvh-var(--ri-header-h,52px)-9rem)]">131 <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs text-muted">132 <span className="inline-flex items-center gap-1.5"><Sparkles className="h-3.5 w-3.5" /> {aiReady ? `Grounded research · ${model}` : 'AI provider not configured'}</span>133 <span>Tool calls are shown inline</span>134 </div>135 <div className="flex-1 space-y-4 overflow-y-auto overscroll-contain px-3 py-3 sm:px-4 sm:py-4">136 {turns.length === 0 ? (137 <div>138 <p className="mb-3 text-sm text-muted">Try one of these:</p>139 <div className="grid gap-2 sm:grid-cols-2">140 {suggestions.map((s) => (141 <button key={s} type="button" disabled={!aiReady || busy} onClick={() => void send(s)} className="rounded-md border border-border bg-sunken px-3 py-2 text-left text-[13px] hover:bg-inset disabled:opacity-50">142 {s}143 </button>144 ))}145 </div>146 </div>147 ) : null}148 {turns.map((t) => (149 <div key={t.id} className={cn('flex', t.role === 'user' ? 'justify-end' : 'justify-start')}>150 <div className={cn('max-w-[94%] overflow-x-auto rounded-lg px-3.5 py-2.5 text-sm sm:max-w-[88%]', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}>151 {t.tools.length ? (152 <ul className="mb-2 space-y-1">153 {t.tools.map((tool) => (154 <ToolRow key={tool.id} tool={tool} />155 ))}156 </ul>157 ) : null}158 {t.role === 'assistant' ? <Markdown text={t.content} /> : <p className="whitespace-pre-wrap">{t.content}</p>}159 {t.streaming && !t.content ? <Loader2 className="mt-1 h-4 w-4 animate-spin text-muted" /> : null}160 {t.error ? <p className="mt-2 rounded-md bg-loss-bg px-2 py-1 text-xs text-loss">{t.error}</p> : null}161 {t.role === 'assistant' && !t.streaming && t.usdEst !== undefined ? <p className="mt-2 text-[10px] text-subtle">Grounded in {t.tools.length} quer{t.tools.length === 1 ? 'y' : 'ies'} · est. cost ${t.usdEst.toFixed(4)}</p> : null}162 </div>163 </div>164 ))}165 <div ref={bottom} />166 </div>167 <form168 className="sticky bottom-0 flex items-end gap-2 border-t border-border bg-elevated p-2 sm:p-3"169 onSubmit={(e) => {170 e.preventDefault();171 void send(input);172 }}173 >174 <textarea175 value={input}176 onChange={(e) => setInput(e.target.value)}177 onKeyDown={(e) => {178 if (e.key === 'Enter' && !e.shiftKey) {179 e.preventDefault();180 void send(input);181 }182 }}183 rows={2}184 placeholder={aiReady ? 'Ask about an asset, a category, an index, movers or a screen…' : 'AI provider not configured'}185 disabled={!aiReady}186 className="max-h-40 min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-base text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none md:text-sm"187 />188 <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50">189 {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}190 </button>191 </form>192 <p className="border-t border-border px-4 py-2 text-[11px] text-subtle">Analytical data, not investment advice. Valuations are estimates with confidence; listing prices are not confirmed transactions.</p>193 </section>194 </div>195 );196}197198function ToolRow({ tool }: { tool: ToolTrace }) {199 const [open, setOpen] = useState(false);200 const args = tool.input && typeof tool.input === 'object' ? Object.entries(tool.input as Record<string, unknown>).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ') : '';201 return (202 <li className="rounded-md border border-border bg-elevated text-[11px]">203 <button type="button" onClick={() => setOpen(!open)} className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono">204 <span className={cn('h-1.5 w-1.5 rounded-full', tool.ok === undefined ? 'animate-pulse bg-subtle' : tool.ok ? 'bg-gain' : 'bg-loss')} />205 <span className="truncate">Queried: {tool.name}({args})</span>206 <span className="ml-auto shrink-0 text-subtle">{tool.summary ?? (tool.ok === undefined ? '…' : '')}{tool.ms !== undefined ? ` · ${tool.ms} ms` : ''}</span>207 <ChevronDown className={cn('h-3 w-3 shrink-0 text-subtle transition-transform', open && 'rotate-180')} />208 </button>209 {open && tool.preview !== undefined ? <pre className="max-h-48 overflow-auto border-t border-border px-2 py-1 font-mono text-[10px] text-muted">{JSON.stringify(tool.preview, null, 1)}</pre> : null}210 </li>211 );212}213