Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 1

Research Agents: Agents you define that answer research chats from SEC filings, your documents, and the web, citing every passage.

7.8 KB
1import { Fragment, useMemo, type ReactElement, type ReactNode } from 'react'
2import type { Definition, List, ListItem, RootContent, Table } from 'mdast'
3import { definitionsOf, isLineBreak, linkHref, parseMarkdown } from './markdown'
4
5// Markdown drawn from its tree: an answer, or a document in its window. Nothing here becomes HTML from
6// a string, so neither can inject markup. Links open in the browser through the bridge, since the
7// frame can open nothing itself; the frame loads no images, so an image is a link to itself. In an
8// answer a citation, [p2], is drawn by `cite`; in a document the blocks a span of the source covers
9// are marked, the passage a search found.
10
11interface Render {
12 definitions: Map<string, Definition>
13 open: (href: string) => void
14 cite?: (ref: string, key: string) => ReactNode
15}
16
17const CITATION = /\[(p\d+)\]/g
18
19export function Markdown({
20 source,
21 onLink,
22 cite,
23 mark,
24}: {
25 source: string
26 onLink: (href: string) => void
27 cite?: (ref: string, key: string) => ReactNode
28 /** A span of `source`, by character offsets, whose blocks are marked. */
29 mark?: { start: number; end: number } | null
30}): ReactElement {
31 const tree = useMemo(() => parseMarkdown(source), [source])
32 const render: Render = { definitions: definitionsOf(tree), open: onLink, cite }
33 return (
34 <div className="ag-md">
35 {tree.children.map((node, i) =>
36 mark && covers(node, mark) ? (
37 <div key={i} className="ag-mark">
38 {block(node, i, render)}
39 </div>
40 ) : (
41 block(node, i, render)
42 ),
43 )}
44 </div>
45 )
46}
47
48/** Whether a block of the source lies at least partly in the span. */
49function covers(node: RootContent, span: { start: number; end: number }): boolean {
50 const start = node.position?.start.offset
51 const end = node.position?.end.offset
52 return start !== undefined && end !== undefined && start < span.end && end > span.start
53}
54
55function blocks(nodes: RootContent[], render: Render): ReactNode[] {
56 return nodes.map((node, i) => block(node, i, render))
57}
58
59function block(node: RootContent, key: number, render: Render): ReactNode {
60 switch (node.type) {
61 case 'heading': {
62 const Tag = `h${node.depth}` as const
63 return <Tag key={key}>{inlines(node.children, render)}</Tag>
64 }
65 case 'paragraph':
66 return <p key={key}>{inlines(node.children, render)}</p>
67 case 'list':
68 return list(node, key, render)
69 case 'code':
70 return (
71 <pre key={key}>
72 <code>{node.value}</code>
73 </pre>
74 )
75 case 'blockquote':
76 return <blockquote key={key}>{blocks(node.children, render)}</blockquote>
77 case 'table':
78 return table(node, key, render)
79 case 'thematicBreak':
80 return <hr key={key} />
81 case 'html':
82 return isLineBreak(node.value) ? (
83 <br key={key} />
84 ) : (
85 <p key={key} className="ag-md-html">
86 {node.value}
87 </p>
88 )
89 case 'footnoteDefinition':
90 return (
91 <div key={key} className="ag-md-footnote">
92 <span>[{node.label ?? node.identifier}]</span>
93 <div>{blocks(node.children, render)}</div>
94 </div>
95 )
96 case 'definition':
97 return null
98 default:
99 return inline(node, key, render)
100 }
101}
102
103/** A tight list's items hold their words directly; a loose list's hold paragraphs. */
104function list(node: List, key: number, render: Render): ReactNode {
105 const loose = node.spread === true || node.children.some((item) => item.spread === true)
106 const items = node.children.map((item, i) => listItem(item, i, loose, render))
107 return node.ordered ? (
108 <ol key={key} start={node.start ?? undefined}>
109 {items}
110 </ol>
111 ) : (
112 <ul key={key}>{items}</ul>
113 )
114}
115
116function listItem(item: ListItem, key: number, loose: boolean, render: Render): ReactNode {
117 const task = typeof item.checked === 'boolean'
118 return (
119 <li key={key} className={task ? 'ag-md-task-item' : undefined}>
120 {task && <span className="ag-md-task">{item.checked ? '☑' : '☐'}</span>}
121 {item.children.map((child, i) =>
122 !loose && child.type === 'paragraph' ? (
123 <Fragment key={i}>{inlines(child.children, render)}</Fragment>
124 ) : (
125 block(child, i, render)
126 ),
127 )}
128 </li>
129 )
130}
131
132function table(node: Table, key: number, render: Render): ReactNode {
133 const [head, ...rows] = node.children
134 const align = (column: number) => node.align?.[column] ?? undefined
135 return (
136 <div key={key} className="ag-md-table">
137 <table>
138 {head && (
139 <thead>
140 <tr>
141 {head.children.map((cell, i) => (
142 <th key={i} style={{ textAlign: align(i) }}>
143 {inlines(cell.children, render)}
144 </th>
145 ))}
146 </tr>
147 </thead>
148 )}
149 <tbody>
150 {rows.map((row, r) => (
151 <tr key={r}>
152 {row.children.map((cell, i) => (
153 <td key={i} style={{ textAlign: align(i) }}>
154 {inlines(cell.children, render)}
155 </td>
156 ))}
157 </tr>
158 ))}
159 </tbody>
160 </table>
161 </div>
162 )
163}
164
165function inlines(nodes: RootContent[], render: Render): ReactNode[] {
166 return nodes.map((node, i) => inline(node, i, render))
167}
168
169function inline(node: RootContent, key: number, render: Render): ReactNode {
170 switch (node.type) {
171 case 'text':
172 return render.cite ? cited(node.value, key, render.cite) : node.value
173 case 'emphasis':
174 return <em key={key}>{inlines(node.children, render)}</em>
175 case 'strong':
176 return <strong key={key}>{inlines(node.children, render)}</strong>
177 case 'delete':
178 return <del key={key}>{inlines(node.children, render)}</del>
179 case 'inlineCode':
180 return <code key={key}>{node.value}</code>
181 case 'break':
182 return <br key={key} />
183 case 'html':
184 return isLineBreak(node.value) ? <br key={key} /> : node.value
185 case 'link':
186 return link(linkHref(node.url), inlines(node.children, render), key, render)
187 case 'linkReference':
188 return link(reference(node.identifier, render), inlines(node.children, render), key, render)
189 case 'image':
190 return image(linkHref(node.url), node.alt, key, render)
191 case 'imageReference':
192 return image(reference(node.identifier, render), node.alt, key, render)
193 case 'footnoteReference':
194 return <sup key={key}>[{node.label ?? node.identifier}]</sup>
195 default:
196 return 'children' in node ? <Fragment key={key}>{inlines(node.children, render)}</Fragment> : null
197 }
198}
199
200/** An https link opens in the browser through main; any other reads as its words. */
201function link(href: string | null, children: ReactNode, key: number, render: Render): ReactNode {
202 if (!href) return <Fragment key={key}>{children}</Fragment>
203 return (
204 <a
205 key={key}
206 href={href}
207 title={href}
208 onClick={(event) => {
209 event.preventDefault()
210 render.open(href)
211 }}
212 >
213 {children}
214 </a>
215 )
216}
217
218function image(href: string | null, alt: string | null | undefined, key: number, render: Render): ReactNode {
219 const name = alt?.trim() || null
220 return href ? link(href, `Image: ${name ?? href}`, key, render) : name
221}
222
223function reference(identifier: string, render: Render): string | null {
224 const definition = render.definitions.get(identifier)
225 return definition ? linkHref(definition.url) : null
226}
227
228/** Text with its citations drawn as markers. */
229function cited(text: string, key: number, cite: (ref: string, key: string) => ReactNode): ReactNode {
230 const parts: ReactNode[] = []
231 let last = 0
232 for (const match of text.matchAll(CITATION)) {
233 if (match.index > last) parts.push(text.slice(last, match.index))
234 parts.push(cite(match[1]!, `${key}-${match.index}`))
235 last = match.index + match[0].length
236 }
237 if (last === 0) return text
238 parts.push(text.slice(last))
239 return <Fragment key={key}>{parts}</Fragment>
240}