Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.
| 1 | import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react' |
| 2 | import { useData } from '@jaspers-ai/sdk' |
| 3 | import { numberCitations, numberKey, sourceTitle, type Citation } from './citations' |
| 4 | import { CiteMark, Markdown, type CiteLookup } from './MarkdownView' |
| 5 | import { answerText, type AnalystMessage, type Message, type Step } from './room' |
| 6 | |
| 7 | // One message in the thread, reading its own live value, so an analyst's progress re-renders its row |
| 8 | // and nothing else. An answer's tool calls sit where the analyst made them: the text is cut at each |
| 9 | // step's offset, and the chips go between the pieces. Its sources are listed under it, folded until |
| 10 | // opened, each a link to the filing on sec.gov that opens on the quoted words. Once it is in, the copy |
| 11 | // icon under its text, above the sources, puts the answer and its sources on the clipboard. |
| 12 | |
| 13 | interface Props { |
| 14 | room: string |
| 15 | seq: number |
| 16 | names: Record<string, string> |
| 17 | colors: Record<string, string | null> |
| 18 | onLink: (href: string) => void |
| 19 | onFile: (path: string) => void |
| 20 | /** Resolves true once the text is on the clipboard; a failure is the room's to show. */ |
| 21 | onCopy: (text: string) => Promise<boolean> |
| 22 | } |
| 23 | |
| 24 | export function MessageRow({ room, seq, names, colors, onLink, onFile, onCopy }: Props): ReactElement | null { |
| 25 | const message = useData(`live/rooms/${room}/m/${seq}`) as Message | undefined |
| 26 | const text = message?.role === 'analyst' ? message.text : '' |
| 27 | const citations = message?.role === 'analyst' ? message.citations : undefined |
| 28 | // Numbered from the text, as the runner numbers them, so a marker shows before the server has answered. |
| 29 | const cite = useMemo<CiteLookup>(() => { |
| 30 | const numbered = numberCitations(text) |
| 31 | return { |
| 32 | numbers: new Map(numbered.map((c) => [numberKey(c.attrs) ?? '', c.n])), |
| 33 | resolved: new Map((citations ?? []).map((c) => [c.n, c])), |
| 34 | } |
| 35 | }, [text, citations]) |
| 36 | const copy = useMemo(() => (message?.role === 'analyst' ? answerText(message) : ''), [message]) |
| 37 | if (!message) return null |
| 38 | if (message.role === 'user') { |
| 39 | return ( |
| 40 | <div className="rs-msg rs-you"> |
| 41 | <div className="rs-meta"> |
| 42 | <span className="rs-name">You</span> |
| 43 | <span className="rs-muted">via Jaspers → {message.to.map((id) => names[id] ?? id).join(', ')}</span> |
| 44 | </div> |
| 45 | <div className="rs-plain">{message.text}</div> |
| 46 | </div> |
| 47 | ) |
| 48 | } |
| 49 | return ( |
| 50 | <div className="rs-msg"> |
| 51 | <div className="rs-meta"> |
| 52 | <span className="rs-swatch" style={{ background: message.color ?? colors[message.analyst] ?? 'var(--jaspers-muted-foreground, #737373)' }} aria-hidden /> |
| 53 | <span className="rs-name">{message.name}</span> |
| 54 | <span className={message.status === 'error' ? 'rs-error' : 'rs-muted'}>{statusText(message)}</span> |
| 55 | </div> |
| 56 | {body(message, onLink, cite)} |
| 57 | {copy && <CopyButton text={copy} onCopy={onCopy} />} |
| 58 | {cite.numbers.size > 0 && <Sources message={message} cite={cite} onLink={onLink} />} |
| 59 | {message.files.length > 0 && ( |
| 60 | <div className="rs-files"> |
| 61 | {message.files.map((file) => ( |
| 62 | <button key={file.path} type="button" className="rs-file" onClick={() => onFile(file.path)} title={file.path}> |
| 63 | {file.path.slice(file.path.lastIndexOf('/') + 1)} |
| 64 | <span className="rs-muted"> {size(file.size)}</span> |
| 65 | </button> |
| 66 | ))} |
| 67 | </div> |
| 68 | )} |
| 69 | </div> |
| 70 | ) |
| 71 | } |
| 72 | |
| 73 | function body(message: AnalystMessage, onLink: (href: string) => void, cite: CiteLookup): ReactNode[] { |
| 74 | const parts: ReactNode[] = [] |
| 75 | const offsets = [...new Set(message.steps.map((s) => s.at))].sort((a, b) => a - b) |
| 76 | let at = 0 |
| 77 | for (const offset of offsets) { |
| 78 | const piece = message.text.slice(at, offset) |
| 79 | if (piece.trim()) parts.push(<Markdown key={`t${at}`} source={piece} onLink={onLink} cite={cite} />) |
| 80 | parts.push(<Steps key={`s${offset}`} steps={message.steps.filter((s) => s.at === offset)} />) |
| 81 | at = offset |
| 82 | } |
| 83 | const rest = message.text.slice(at) |
| 84 | if (rest.trim()) parts.push(<Markdown key={`t${at}`} source={rest} onLink={onLink} cite={cite} />) |
| 85 | if (message.status === 'queued') parts.push(<p key="queued" className="rs-muted">Waiting to start.</p>) |
| 86 | if (message.status === 'working' && message.steps.length === 0 && !message.text) parts.push(<p key="start" className="rs-muted">Reading the question…</p>) |
| 87 | if (message.error && message.status !== 'done') { |
| 88 | parts.push( |
| 89 | <p key="error" className={message.status === 'error' ? 'rs-error' : 'rs-muted'}> |
| 90 | {message.status === 'error' ? `Error: ${message.error}` : `Stopped: ${message.error}`} |
| 91 | </p>, |
| 92 | ) |
| 93 | } |
| 94 | return parts |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * The answer's sources, folded to one line until opened: how many, and what the audit found. Open, one |
| 99 | * line each: the marker, what the filing is, the quoted words, and the verdict. The markers in the |
| 100 | * answer link to the same pages, so the list is there to check, not to read every time. |
| 101 | */ |
| 102 | function Sources({ message, cite, onLink }: { message: AnalystMessage; cite: CiteLookup; onLink: (href: string) => void }): ReactElement { |
| 103 | const numbered = numberCitations(message.text) |
| 104 | const checking = message.status === 'working' || (message.status === 'done' && (message.citations ?? []).length === 0) |
| 105 | const audit = message.audit |
| 106 | return ( |
| 107 | <details className="rs-sources"> |
| 108 | <summary className="rs-sources-head"> |
| 109 | <span>Sources</span> |
| 110 | <span className="rs-muted"> · {numbered.length}</span> |
| 111 | {audit && (audit.uncited > 0 || audit.unverified > 0) && ( |
| 112 | <span className="rs-muted"> |
| 113 | {' · '} |
| 114 | {[audit.uncited > 0 ? `${audit.uncited} uncited` : '', audit.unverified > 0 ? `${audit.unverified} unverified` : ''].filter(Boolean).join(', ')} |
| 115 | {audit.repaired ? ' after repair' : ''} |
| 116 | </span> |
| 117 | )} |
| 118 | </summary> |
| 119 | {numbered.map((c) => { |
| 120 | const citation: Citation | undefined = cite.resolved.get(c.n) |
| 121 | return ( |
| 122 | <div key={c.n} className="rs-source"> |
| 123 | <CiteMark n={c.n} citation={citation} onLink={onLink} /> |
| 124 | <div className="rs-source-body"> |
| 125 | <span className="rs-source-title">{sourceTitle(c, citation)}</span> |
| 126 | {c.quote && <span className="rs-source-quote">“{c.quote}”</span>} |
| 127 | <span className={`rs-source-state ${citation?.verified === true ? 'ok' : citation?.verified === false ? 'bad' : ''}`}> |
| 128 | {citation?.verified === true ? 'verified' : citation?.verified === false ? 'quote not found in the source' : checking ? 'checking…' : 'unchecked'} |
| 129 | </span> |
| 130 | </div> |
| 131 | </div> |
| 132 | ) |
| 133 | })} |
| 134 | </details> |
| 135 | ) |
| 136 | } |
| 137 | |
| 138 | /** The copy icon under an answer's text, a check for a moment once the clipboard has it. */ |
| 139 | function CopyButton({ text, onCopy }: { text: string; onCopy: (text: string) => Promise<boolean> }): ReactElement { |
| 140 | const [copied, setCopied] = useState(false) |
| 141 | useEffect(() => { |
| 142 | if (!copied) return |
| 143 | const timer = setTimeout(() => setCopied(false), 1500) |
| 144 | return () => clearTimeout(timer) |
| 145 | }, [copied]) |
| 146 | const label = copied ? 'Copied' : 'Copy the answer and its sources' |
| 147 | return ( |
| 148 | <button type="button" className="rs-copy" aria-label={label} title={label} onClick={() => void onCopy(text).then(setCopied)}> |
| 149 | <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden> |
| 150 | {copied ? ( |
| 151 | <path d="M2.75 8.25l3.5 3.5 7-7.5" /> |
| 152 | ) : ( |
| 153 | <> |
| 154 | <rect x="5.25" y="5.25" width="8.5" height="8.5" /> |
| 155 | <path d="M10.75 5.25v-3h-8.5v8.5h3" /> |
| 156 | </> |
| 157 | )} |
| 158 | </svg> |
| 159 | </button> |
| 160 | ) |
| 161 | } |
| 162 | |
| 163 | function Steps({ steps }: { steps: Step[] }): ReactElement { |
| 164 | return ( |
| 165 | <div className="rs-steps"> |
| 166 | {steps.map((step, i) => ( |
| 167 | <details key={i} className={`rs-step${step.isError ? ' rs-step-error' : ''}`}> |
| 168 | <summary> |
| 169 | <code>{step.tool}</code> |
| 170 | <span className="rs-muted"> {preview(step.args)}</span> |
| 171 | <span className="rs-muted"> · {step.summary ?? 'running…'}</span> |
| 172 | </summary> |
| 173 | <pre>{step.args}</pre> |
| 174 | </details> |
| 175 | ))} |
| 176 | </div> |
| 177 | ) |
| 178 | } |
| 179 | |
| 180 | function statusText(message: AnalystMessage): string { |
| 181 | switch (message.status) { |
| 182 | case 'queued': |
| 183 | return 'queued' |
| 184 | case 'working': |
| 185 | return `working · turn ${message.turns + 1}` |
| 186 | case 'done': |
| 187 | return message.endedAt ? time(message.endedAt) : 'done' |
| 188 | case 'stopped': |
| 189 | return 'stopped' |
| 190 | case 'error': |
| 191 | return 'error' |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | /** The arguments' values, briefly: what a chip shows before it is opened. */ |
| 196 | function preview(args: string): string { |
| 197 | try { |
| 198 | const values = Object.values(JSON.parse(args) as Record<string, unknown>) |
| 199 | .map((v) => (typeof v === 'string' ? `"${v}"` : JSON.stringify(v))) |
| 200 | .join(' ') |
| 201 | return values.length > 60 ? `${values.slice(0, 60)}…` : values |
| 202 | } catch { |
| 203 | return args.length > 60 ? `${args.slice(0, 60)}…` : args |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | function time(iso: string): string { |
| 208 | const date = new Date(iso) |
| 209 | return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) |
| 210 | } |
| 211 | |
| 212 | function size(bytes: number): string { |
| 213 | return bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB` |
| 214 | } |