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.

11 KB
1import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
2import { useBridge, useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk'
3import type { Analyst } from './analysts'
4import { MessageRow } from './Message'
5import type { RoomLive } from './rooms'
6import './styles.css'
7
8// The research room. With no room in its state it lists the rooms there are and the analysts a room
9// can have; with one, it is the thread: who is in the room and busy, every message as it grows, and
10// the controls that need no words (stop, remove an analyst, show the folder). Talking to the room is
11// the composer's job, through the orchestrator.
12
13type Listed = Omit<Analyst, 'instructions'>
14
15/** A room the orchestrator asked for by placing the view, not yet made. */
16interface Start {
17 name: string
18 analysts: string[]
19 tickers?: string[]
20 ask?: string
21}
22
23interface RoomState {
24 room?: string
25 start?: Start
26}
27
28/** The panel's state arrives a round trip after the frame mounts; nothing is shown as a room until it has. */
29export function RoomView({ panel }: { panel: PanelRef }): ReactElement {
30 const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as RoomState | undefined
31 if (state === undefined) {
32 return (
33 <div className="rs-root">
34 <div className="rs-bar">
35 <span className="rs-muted">Loading…</span>
36 </div>
37 </div>
38 )
39 }
40 return <Room panel={panel} initial={state} />
41}
42
43/**
44 * Which room this is. state.start is a room to make: the view creates it, posts its first question,
45 * and writes the new id to state.room, so the orchestrator starts a room with one placement and never
46 * has to know an id first. A state.room that names no room shows the rooms there are, and says so.
47 */
48function Room({ panel, initial }: { panel: PanelRef; initial: RoomState }): ReactElement {
49 const bridge = useBridge()
50 const [room, setRoom] = usePanelState<string | undefined>(panel, 'room', initial.room)
51 const [start, setStart] = usePanelState<Start | undefined>(panel, 'start', initial.start)
52 const analysts = (useData('live/analysts') as Listed[] | undefined) ?? []
53 const [missing, setMissing] = useState<string | null>(null)
54 const [failure, setFailure] = useState<string | null>(null)
55 const [note, setNote] = useState<string | null>(null)
56 const creating = useRef(false)
57
58 // A different room, however it was set, gets its own chance to load.
59 useEffect(() => {
60 setMissing(null)
61 setFailure(null)
62 }, [room])
63
64 useEffect(() => {
65 if (!start || creating.current || (room && !missing)) return
66 creating.current = true
67 setFailure(null)
68 void (async () => {
69 try {
70 const made = await bridge.runSource('research/create-room', { name: start.name, analysts: start.analysts, tickers: start.tickers ?? [] })
71 const result = made.kind === 'text' ? (JSON.parse(made.text) as { room?: string; analysts?: string[]; unmatched?: string[] }) : {}
72 const id = result.room
73 if (!id) throw new Error('The room was not created.')
74 setMissing(null)
75 setRoom(id)
76 setStart(undefined)
77 const left = result.unmatched ?? []
78 setNote(left.length > 0 ? `Left out ${left.join(', ')}: no analyst by that name.` : null)
79 if (start.ask?.trim()) await bridge.runSource('research/post', { room: id, text: start.ask, to: result.analysts ?? start.analysts })
80 } catch (err) {
81 setFailure(`Could not start ${start.name}: ${err instanceof Error ? err.message : String(err)}`)
82 setStart(undefined)
83 } finally {
84 creating.current = false
85 }
86 })()
87 }, [bridge, start, room, missing, setRoom, setStart])
88
89 if (room && !missing) {
90 return <Thread key={room} panel={panel} room={room} analysts={analysts} note={note} onLeave={() => setRoom(undefined)} onMissing={setMissing} />
91 }
92 const notice = start ? null : failure ?? (missing && room ? `There is no room "${room}". Open one below, or ask Jaspers to start a room.` : null)
93 return (
94 <Picker
95 panel={panel}
96 analysts={analysts}
97 starting={start?.name ?? null}
98 notice={notice}
99 onOpen={(id) => {
100 setMissing(null)
101 setFailure(null)
102 setRoom(id)
103 }}
104 />
105 )
106}
107
108function Picker({
109 panel,
110 analysts,
111 starting,
112 notice,
113 onOpen,
114}: {
115 panel: PanelRef
116 analysts: Listed[]
117 starting: string | null
118 notice: string | null
119 onOpen: (room: string) => void
120}): ReactElement {
121 const { data, loading, error } = useData('research/rooms', {})
122 const rooms = (data ?? []) as { id: string; name: string; tickers: string[]; analysts: string[]; working: string[] }[]
123 usePublish(panel, {
124 room: null,
125 available: analysts.filter((a) => a.error === null).length,
126 ...(starting ? { starting } : {}),
127 ...(notice ? { error: notice } : {}),
128 })
129 return (
130 <div className="rs-root">
131 <div className="rs-bar">
132 <span className="rs-title">Research rooms</span>
133 <span className="rs-muted">Ask Jaspers: “start a research room on FLWS with Credit and Risk”.</span>
134 </div>
135 <div className="rs-scroll">
136 {starting && <p className="rs-muted rs-pad">Starting {starting}…</p>}
137 {notice && <p className="rs-error rs-pad">{notice}</p>}
138 {error && <p className="rs-error rs-pad">{error}</p>}
139 {loading && data === undefined && <p className="rs-muted rs-pad">Loading…</p>}
140 {!loading && !error && rooms.length === 0 && !starting && <p className="rs-muted rs-pad">No rooms on this workspace yet.</p>}
141 {rooms.map((room) => (
142 <div key={room.id} className="rs-row">
143 <div className="rs-body">
144 <div className="rs-line">
145 <span className="rs-name">{room.name}</span>
146 <span className="rs-muted">{room.tickers.join(', ')}</span>
147 </div>
148 <div className="rs-muted">
149 {room.analysts.join(', ') || 'no analysts'}
150 {room.working.length > 0 ? ` · ${room.working.join(', ')} working` : ''}
151 </div>
152 </div>
153 <button type="button" className="rs-btn" onClick={() => onOpen(room.id)}>
154 Open
155 </button>
156 </div>
157 ))}
158 <div className="rs-section">Analysts</div>
159 {analysts.map((analyst) => (
160 <div key={analyst.id} className="rs-row">
161 <span className="rs-swatch" style={{ background: analyst.color ?? 'var(--jaspers-muted-foreground, #737373)' }} aria-hidden />
162 <div className="rs-body">
163 <div className="rs-line">
164 <span className="rs-name">{analyst.name}</span>
165 <span className="rs-muted">
166 {analyst.id}
167 {analyst.origin === 'user' ? ' · yours' : ''}
168 </span>
169 </div>
170 {analyst.error !== null ? <div className="rs-error">{analyst.error}</div> : <div className="rs-muted rs-clamp">{analyst.description}</div>}
171 </div>
172 </div>
173 ))}
174 </div>
175 </div>
176 )
177}
178
179function Thread({
180 panel,
181 room,
182 analysts,
183 note,
184 onLeave,
185 onMissing,
186}: {
187 panel: PanelRef
188 room: string
189 analysts: Listed[]
190 note: string | null
191 onLeave: () => void
192 onMissing: (message: string) => void
193}): ReactElement {
194 const bridge = useBridge()
195 const live = useData(`live/rooms/${room}`) as RoomLive | undefined
196 // The room is loaded into the host when the view opens, and again if the host restarted and its
197 // live values went away with it.
198 const [attempt, setAttempt] = useState(0)
199 const { error } = useData('research/load-room', { room, attempt })
200 // A room id that names no room goes back to the list, which says so.
201 useEffect(() => {
202 if (error && /^No room /.test(error)) onMissing(error)
203 }, [error, onMissing])
204 const had = useRef(false)
205 useEffect(() => {
206 if (live) had.current = true
207 else if (had.current) {
208 had.current = false
209 setAttempt((n) => n + 1)
210 }
211 }, [live])
212 usePublish(panel, live ? { ...live.output } : { room })
213
214 const names = useMemo(() => Object.fromEntries(analysts.map((a) => [a.id, a.name])), [analysts])
215 const colors = useMemo(() => Object.fromEntries(analysts.map((a) => [a.id, a.color])), [analysts])
216 const [problem, setProblem] = useState<string | null>(null)
217 const act = (source: string, args: Record<string, unknown>): void => {
218 setProblem(null)
219 bridge.runSource(source, args).catch((err: unknown) => setProblem(err instanceof Error ? err.message : String(err)))
220 }
221 const copy = (text: string): Promise<boolean> => {
222 setProblem(null)
223 return bridge.copyText(text).then(
224 () => true,
225 (err: unknown) => {
226 setProblem(err instanceof Error ? err.message : String(err))
227 return false
228 },
229 )
230 }
231
232 // Follow new content while the reader is at the bottom; leave them be once they scroll up.
233 const scroller = useRef<HTMLDivElement>(null)
234 const content = useRef<HTMLDivElement>(null)
235 const pinned = useRef(true)
236 useEffect(() => {
237 const box = scroller.current
238 const inner = content.current
239 if (!box || !inner) return
240 const observer = new ResizeObserver(() => {
241 if (pinned.current) box.scrollTop = box.scrollHeight
242 })
243 observer.observe(inner)
244 return () => observer.disconnect()
245 }, [])
246
247 const output = live?.output
248 const busy = output?.analysts.some((a) => a.status !== 'idle') ?? false
249 return (
250 <div className="rs-root">
251 <div className="rs-bar">
252 <button type="button" className="rs-link" onClick={onLeave}>
253 Rooms
254 </button>
255 <span className="rs-title">{live?.room.name ?? room}</span>
256 {live?.room.tickers.map((ticker) => (
257 <span key={ticker} className="rs-tag">
258 {ticker}
259 </span>
260 ))}
261 <span className="rs-roster">
262 {output?.analysts.map((analyst) => (
263 <span key={analyst.id} className="rs-chip">
264 <span className="rs-swatch" style={{ background: colors[analyst.id] ?? 'var(--jaspers-muted-foreground, #737373)' }} aria-hidden />
265 {analyst.name}
266 {analyst.status !== 'idle' && <span className="rs-muted"> {analyst.status}</span>}
267 <button
268 type="button"
269 className="rs-x"
270 aria-label={`Remove ${analyst.name}`}
271 onClick={() => act('research/update-room', { room, analysts: live!.room.analysts.filter((id) => id !== analyst.id) })}
272 >
273 ×
274 </button>
275 </span>
276 ))}
277 </span>
278 {busy && (
279 <button type="button" className="rs-btn" onClick={() => act('research/stop', { room })}>
280 Stop
281 </button>
282 )}
283 <button type="button" className="rs-btn" onClick={() => act('research/reveal', { room })}>
284 Show folder
285 </button>
286 </div>
287 {note && <p className="rs-muted rs-pad">{note}</p>}
288 {(error || problem) && <p className="rs-error rs-pad">{problem ?? error}</p>}
289 <div
290 className="rs-scroll"
291 ref={scroller}
292 onScroll={(event) => {
293 const box = event.currentTarget
294 pinned.current = box.scrollHeight - box.scrollTop - box.clientHeight < 40
295 }}
296 >
297 <div ref={content} className="rs-thread">
298 {live && live.seqs.length === 0 && (
299 <p className="rs-muted rs-pad">No messages yet. Talk to the room through Jaspers: “ask Credit what the refinancing risk is”.</p>
300 )}
301 {live?.seqs.map((seq) => (
302 <MessageRow
303 key={seq}
304 room={room}
305 seq={seq}
306 names={names}
307 colors={colors}
308 onLink={(href) => void bridge.openLink(href).catch((err: unknown) => setProblem(err instanceof Error ? err.message : String(err)))}
309 onFile={(path) => act('research/open-file', { room, path })}
310 onCopy={copy}
311 />
312 ))}
313 </div>
314 </div>
315 </div>
316 )
317}