Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 1

Research Agents: Lets users define research agents that answer chat questions from SEC filings, uploaded documents, and the web, with every figure cited.

5.7 KB
1import { useEffect, useRef, useState, type ReactElement } from 'react'
2import { useBridge } from '@jaspers-ai/sdk'
3import { isCalculation, type Passage } from './chat'
4import { Markdown } from './MarkdownView'
5import type { Outline } from './research'
6
7// A cited passage in its document: the document's sections in order, the cited one open and drawn
8// as Markdown with the passage marked and scrolled to, the rest open on a click. A 10-K is hundreds of
9// thousands of characters, so a section is read only when it is opened; what was read is kept for
10// the life of the view. A cited calculation is no document: the window shows its lines, worked out.
11
12interface Opened extends Outline {
13 /** The section open first, and its text. */
14 open: number | undefined
15 text: string
16}
17
18const documents = new Map<string, Promise<Opened>>()
19const sectionTexts = new Map<string, Promise<string>>()
20
21/** A passage's id is `<document>#<chunk>`. */
22export function placeOf(passage: Passage): { doc: string; chunk: number | null } {
23 const hash = passage.id.lastIndexOf('#')
24 if (hash === -1) return { doc: passage.id, chunk: null }
25 const chunk = Number(passage.id.slice(hash + 1))
26 return { doc: passage.id.slice(0, hash), chunk: Number.isInteger(chunk) ? chunk : null }
27}
28
29export function DocWindow({
30 passage,
31 pinned,
32 onPin,
33 onClose,
34 onEnter,
35 onLeave,
36 onLink,
37}: {
38 passage: Passage
39 pinned: boolean
40 onPin: () => void
41 onClose: () => void
42 onEnter: () => void
43 onLeave: () => void
44 onLink: (href: string) => void
45}): ReactElement {
46 const bridge = useBridge()
47 const [opened, setOpened] = useState<Opened | null>(null)
48 const [failure, setFailure] = useState<string | null>(null)
49 const [texts, setTexts] = useState<Map<number, string | null>>(new Map())
50 const body = useRef<HTMLDivElement>(null)
51 const { doc, chunk } = placeOf(passage)
52
53 const calculation = isCalculation(passage)
54
55 useEffect(() => {
56 setOpened(null)
57 setFailure(null)
58 if (calculation) return
59 const key = passage.id
60 if (!documents.has(key)) {
61 documents.set(
62 key,
63 bridge.runSource('agents/document', chunk === null ? { doc } : { doc, chunk }).then((result) => {
64 if (result.kind !== 'text') throw new Error('The document did not come back.')
65 return JSON.parse(result.text) as Opened
66 }),
67 )
68 }
69 let live = true
70 documents.get(key)!.then(
71 (got) => {
72 if (!live) return
73 setOpened(got)
74 setTexts(new Map(got.open === undefined ? [] : [[got.open, got.text]]))
75 },
76 (err: unknown) => {
77 documents.delete(key)
78 if (live) setFailure(err instanceof Error ? err.message : String(err))
79 },
80 )
81 return () => {
82 live = false
83 }
84 }, [bridge, passage.id, doc, chunk, calculation])
85
86 // The passage in view once its section is drawn. Braces: scrollIntoView returns a promise here.
87 useEffect(() => {
88 body.current?.querySelector('.ag-mark')?.scrollIntoView({ block: 'center' })
89 }, [opened])
90
91 const toggle = (seq: number): void => {
92 if (texts.has(seq)) {
93 const next = new Map(texts)
94 next.delete(seq)
95 setTexts(next)
96 return
97 }
98 setTexts(new Map(texts).set(seq, null))
99 const key = `${doc}/${seq}`
100 if (!sectionTexts.has(key)) {
101 sectionTexts.set(
102 key,
103 bridge.runSource('agents/document-section', { doc, seq }).then((r) => (r.kind === 'text' ? r.text : '')),
104 )
105 }
106 sectionTexts.get(key)!.then(
107 (text) => setTexts((now) => (now.has(seq) ? new Map(now).set(seq, text) : now)),
108 (err: unknown) => {
109 sectionTexts.delete(key)
110 setFailure(err instanceof Error ? err.message : String(err))
111 },
112 )
113 }
114
115 const url = opened?.doc.url ?? passage.url
116 return (
117 <div className="ag-docwin" onMouseEnter={onEnter} onMouseLeave={onLeave} role="dialog" aria-label={passage.label}>
118 <div className="ag-bar">
119 <span className="ag-title ag-line" title={passage.label}>
120 [{passage.ref}] {passage.label}
121 </span>
122 <span className="ag-spacer" />
123 {url && (
124 <button type="button" className="ag-link" onClick={() => onLink(url)}>
125 Source
126 </button>
127 )}
128 <button type="button" className="ag-link" onClick={onPin}>
129 {pinned ? 'Unpin' : 'Pin'}
130 </button>
131 <button type="button" className="ag-link" onClick={onClose} aria-label="Close">
132 ×
133 </button>
134 </div>
135 <div className="ag-docbody" ref={body}>
136 {calculation && <div className="ag-text">{passage.text}</div>}
137 {failure && <div className="ag-error">{failure}</div>}
138 {!calculation && !opened && !failure && <div className="ag-muted">Opening the document…</div>}
139 {opened?.sections.map((section) => {
140 const text = texts.get(section.seq)
141 const open = texts.has(section.seq)
142 return (
143 <section key={section.seq} className="ag-docsection">
144 <button type="button" className="ag-dochead" onClick={() => toggle(section.seq)}>
145 <span>{open ? '▾' : '▸'}</span>
146 <span className="ag-name">{section.section}</span>
147 {section.title && <span>{section.title}</span>}
148 <span className="ag-muted">{section.chars.toLocaleString()} characters</span>
149 </button>
150 {open &&
151 (text === null || text === undefined ? (
152 <div className="ag-muted">Reading…</div>
153 ) : (
154 <Markdown
155 source={text}
156 onLink={onLink}
157 mark={opened.chunk && opened.chunk.section === section.seq ? opened.chunk : null}
158 />
159 ))}
160 </section>
161 )
162 })}
163 </div>
164 </div>
165 )
166}