Jaspers Hub

jaspers / research

pluginOfficialReviewed★ 0↓ 28

Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.

9.2 KB
1import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'
2import { useData } from '@jaspers-ai/sdk'
3import { numberCitations, numberKey, sourceTitle, type Citation } from './citations'
4import { CiteMark, Markdown, type CiteLookup } from './MarkdownView'
5import { 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
13interface 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
24export 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
73function 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 */
102function 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. */
139function 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
163function 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
180function 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. */
196function 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
207function 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
212function 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}