Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 1

Research Agents: Lets you define research agents that chat and answer with citations from SEC filings, your documents, and the web, via Jaspers' research-store service.

32 KB
1import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
2import { useBridge, useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk'
3import { agentIdFrom, emptySearch, ORIGINS, type Agent } from './agents'
4import { companiesUsed, type AgentMessage, type Chat, type Message, type Passage, type Search } from './chat'
5import type { CompanyStatus } from './sec'
6import { CompanyChip, useCompanyCard } from './Company'
7import { AddButton, CopyButton, EditButton, IconButton, StopButton } from './buttons'
8import { withSources } from './cite'
9import { Attach } from './Attach'
10import { DocWindow } from './DocWindow'
11import { isMarkup, isPdf, type Doc } from './docs'
12import { Markdown } from './MarkdownView'
13import { Mic } from './Mic'
14import { readPdf } from './pdf'
15import { Setup } from './Setup'
16import type { Setup as SetupState } from './status'
17import './styles.css'
18
19// Each panel is one conversation: the agents on the left, and under them the companies the agents read
20// in during this conversation; the conversation on the right, its documents behind the paperclip
21// beside the message box. A panel placed anew starts a new conversation; its own conversation and
22// whether its side panel is folded are the panel's state, so a view comes back as it was.
23// A click on an agent tags it in the message being written; its pencil opens the form where the
24// conversation was. Typing @ offers the agents whose ids start with what follows. Files dropped
25// anywhere on the view, or picked with Upload, are read here as text and indexed by the plugin.
26
27/** The panel's state arrives a round trip after the frame mounts; nothing is opened until it has. */
28export function AgentsView({ panel }: { panel: PanelRef }): ReactElement {
29 const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as
30 { conversation?: string; folded?: boolean; ask?: string } | undefined
31 if (state === undefined) return <div className="ag-root ag-empty ag-muted">Loading…</div>
32 return (
33 <Agents panel={panel} initial={state.conversation} initialFolded={state.folded ?? false} initialAsk={state.ask} />
34 )
35}
36
37function Agents({
38 panel,
39 initial,
40 initialFolded,
41 initialAsk,
42}: {
43 panel: PanelRef
44 initial: string | undefined
45 initialFolded: boolean
46 initialAsk: string | undefined
47}): ReactElement {
48 const bridge = useBridge()
49 const agents = (useData('live/agents') as Agent[] | undefined) ?? []
50 const [conversation, setConversation] = usePanelState<string | undefined>(panel, 'conversation', initial)
51 const [folded, setFolded] = usePanelState(panel, 'folded', initialFolded)
52 // A question the panel was placed with: posted once its conversation is open, then cleared.
53 const [ask, setAsk] = usePanelState<string | undefined>(panel, 'ask', initialAsk)
54 const asked = useRef(false)
55 const [open, setOpen] = useState<string | null>(null)
56 const chat = useData(`live/chat/${open ?? 'none'}`) as Chat | undefined
57 const [editing, setEditing] = useState<Agent | 'new' | null>(null)
58 const [draft, setDraft] = useState('')
59 const docs = (useData('live/docs') as Doc[] | undefined) ?? []
60 const companies = (useData('live/sec') as CompanyStatus[] | undefined) ?? []
61 const setup = useData('live/setup') as SetupState | undefined
62 const [failure, setFailure] = useState<string | null>(null)
63 const [note, setNote] = useState<string | null>(null)
64 const messages = open ? (chat?.messages ?? []) : []
65 const card = useCompanyCard()
66 // The companies this conversation's agents touched, as the store has them now.
67 const added = companiesUsed(messages).map(
68 (ticker) => companies.find((c) => c.ticker === ticker) ?? { ticker, pending: true as const },
69 )
70
71 // The panel's conversation, or a new one for a panel placed anew: whichever the plugin opened is the state's.
72 useEffect(() => {
73 bridge
74 .runSource('agents/open-conversation', conversation ? { id: conversation } : {})
75 .then((result) => {
76 const id = result.kind === 'text' ? result.text : null
77 setOpen(id)
78 if (id && id !== conversation) setConversation(id)
79 })
80 .catch((err: unknown) => setFailure(messageOf(err)))
81 }, [bridge, conversation, setConversation])
82
83 usePublish(panel, { agents: agents.map((a) => a.id), messages: messages.length })
84
85 const run = (source: string, args: unknown): Promise<void> =>
86 bridge.runSource(`agents/${source}`, args).then(
87 () => setFailure(null),
88 (err: unknown) => {
89 setFailure(messageOf(err))
90 throw err
91 },
92 )
93
94 useEffect(() => {
95 if (!open || !ask?.trim() || asked.current) return
96 asked.current = true
97 void run('post', { conversation: open, text: ask })
98 .catch(() => {})
99 .finally(() => setAsk(undefined))
100 }, [open, ask])
101
102 /** One file at a time, so each says how it went. */
103 const upload = async (files: File[]): Promise<void> => {
104 for (const file of files) {
105 setNote(`Indexing ${file.name}…`)
106 try {
107 const result = await bridge.runSource('agents/upload', {
108 name: file.name,
109 ...(await readDoc(file)),
110 ...(open ? { conversation: open } : {}),
111 })
112 setNote(result.kind === 'text' ? result.text : null)
113 setFailure(null)
114 } catch (err) {
115 setNote(null)
116 setFailure(messageOf(err))
117 }
118 }
119 }
120
121 return (
122 <div
123 className="ag-root"
124 onDragOver={(e) => e.preventDefault()}
125 onDrop={(e) => {
126 e.preventDefault()
127 void upload([...e.dataTransfer.files])
128 }}
129 >
130 {folded ? (
131 <aside className="ag-side ag-folded">
132 <SideToggle folded onToggle={() => setFolded(false)} />
133 </aside>
134 ) : (
135 <aside className="ag-side">
136 <section className="ag-panel ag-open">
137 <div className="ag-bar">
138 <span className="ag-title">Agents</span>
139 <span className="ag-spacer" />
140 <SideToggle folded={false} onToggle={() => setFolded(true)} />
141 <AddButton label="New agent" onClick={() => setEditing('new')} />
142 </div>
143 <div className="ag-panel-body">
144 <ul className="ag-list">
145 {agents.length === 0 && <li className="ag-empty ag-muted">No agents yet. + makes one.</li>}
146 {agents.map((agent) => (
147 <li key={agent.id} className="ag-agent">
148 <button
149 type="button"
150 className="ag-pick"
151 title={`Tag @${agent.id}`}
152 onClick={() => setDraft((d) => withTag(d, agent.id))}
153 >
154 <span
155 className="ag-swatch"
156 style={{ background: agent.color ?? 'var(--jaspers-muted-foreground, #737373)' }}
157 />
158 <span className="ag-body">
159 <span className="ag-name">{agent.name}</span>
160 <span className="ag-muted"> @{agent.id}</span>
161 {agent.error ? (
162 <span className="ag-error ag-line">{agent.error}</span>
163 ) : (
164 agent.description && <span className="ag-muted ag-line">{agent.description}</span>
165 )}
166 </span>
167 </button>
168 <EditButton label={`Edit ${agent.name}`} onClick={() => setEditing(agent)} />
169 </li>
170 ))}
171 </ul>
172 </div>
173 </section>
174 {added.length > 0 && (
175 <section className="ag-panel ag-open">
176 <div className="ag-bar">
177 <span className="ag-title">Companies</span>
178 </div>
179 <div className="ag-panel-body">
180 <div className="ag-chips">
181 {added.map((c) =>
182 'pending' in c ? (
183 <span key={c.ticker} className="ag-muted">
184 {c.ticker}
185 </span>
186 ) : (
187 <CompanyChip
188 key={c.ticker}
189 c={c}
190 run={run}
191 open={card.open === c.ticker}
192 onShow={() => card.show(c.ticker)}
193 onHide={() => card.hide(c.ticker)}
194 />
195 ),
196 )}
197 </div>
198 </div>
199 </section>
200 )}
201 </aside>
202 )}
203 <main className="ag-main">
204 {!setup?.signedIn ? (
205 <Setup setup={setup} />
206 ) : editing ? (
207 <Editor
208 key={editing === 'new' ? 'new' : editing.id}
209 agent={editing === 'new' ? null : editing}
210 taken={agents.map((a) => a.id)}
211 docs={docs}
212 onSave={(input) => run('save-agent', input).then(() => setEditing(null))}
213 onDelete={(id) => run('delete-agent', { id }).then(() => setEditing(null))}
214 onClose={() => setEditing(null)}
215 />
216 ) : (
217 <ChatPane
218 agents={agents}
219 messages={messages}
220 draft={draft}
221 onDraft={setDraft}
222 onSend={(text) =>
223 open ? run('post', { conversation: open, text }) : Promise.reject(new Error('Opening…'))
224 }
225 onStop={() => void (open && run('stop', { conversation: open }).catch(() => {}))}
226 onLink={(href) => void bridge.openLink(href).catch(() => {})}
227 onCopy={(text) => bridge.copyText(text)}
228 docs={docs}
229 note={note}
230 onFiles={(files) => void upload(files)}
231 onDeleteDoc={(id) => void run('delete-doc', { id }).catch(() => {})}
232 />
233 )}
234 {failure && <div className="ag-failure">{failure}</div>}
235 </main>
236 </div>
237 )
238}
239
240function SideToggle({ folded, onToggle }: { folded: boolean; onToggle: () => void }): ReactElement {
241 return (
242 <IconButton label={folded ? 'Show the side panel' : 'Hide the side panel'} onClick={onToggle}>
243 <rect x="3.5" y="4.5" width="17" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" />
244 <line x1="9.5" y1="4.5" x2="9.5" y2="19.5" stroke="currentColor" strokeWidth="1.6" />
245 <path
246 d={folded ? 'M13.5 9.5 16 12l-2.5 2.5' : 'M16 9.5 13.5 12l2.5 2.5'}
247 fill="none"
248 stroke="currentColor"
249 strokeWidth="1.6"
250 />
251 </IconButton>
252 )
253}
254
255function ChatPane({
256 agents,
257 messages,
258 draft,
259 onDraft,
260 onSend,
261 onStop,
262 onLink,
263 onCopy,
264 docs,
265 note,
266 onFiles,
267 onDeleteDoc,
268}: {
269 agents: Agent[]
270 messages: Message[]
271 draft: string
272 onDraft: (text: string) => void
273 onSend: (text: string) => Promise<void>
274 onStop: () => void
275 onLink: (href: string) => void
276 onCopy: (text: string) => Promise<void>
277 docs: Doc[]
278 note: string | null
279 onFiles: (files: File[]) => void
280 onDeleteDoc: (id: string) => void
281}): ReactElement {
282 const box = useRef<HTMLTextAreaElement>(null)
283 const end = useRef<HTMLDivElement>(null)
284 const [caret, setCaret] = useState(0)
285 const names = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
286 const working = messages.some((m) => m.role === 'agent' && m.status === 'working')
287 const partial = /(?:^|\s)@([a-z0-9-]*)$/i.exec(draft.slice(0, caret))?.[1]?.toLowerCase()
288 const offered = partial === undefined ? [] : agents.filter((a) => a.error === null && a.id.startsWith(partial))
289 const preview = usePreview()
290
291 // Braces: this Chromium's scrollIntoView returns a promise, which React would take for a cleanup.
292 useEffect(() => {
293 end.current?.scrollIntoView({ block: 'end' })
294 }, [messages])
295
296 const complete = (id: string): void => {
297 const before = draft.slice(0, caret).replace(/@[a-z0-9-]*$/i, `@${id} `)
298 onDraft(before + draft.slice(caret))
299 setCaret(before.length)
300 box.current?.focus()
301 }
302 const send = (): void => {
303 const text = draft.trim()
304 if (!text) return
305 onSend(text).then(
306 () => onDraft(''),
307 () => {},
308 )
309 }
310
311 return (
312 <>
313 <div className="ag-thread">
314 {messages.length === 0 && (
315 <div className="ag-empty ag-muted">
316 {agents.length === 0
317 ? 'Ask anything about a public company, its filings, or a document you attach. Your first question makes a Research Analyst agent for you; + at the top left makes agents of your own, with their own instructions.'
318 : 'Tag an agent with @ and ask it something.'}
319 </div>
320 )}
321 {messages.map((m) =>
322 m.role === 'user' ? (
323 <div key={m.seq} className="ag-user">
324 {m.text}
325 </div>
326 ) : (
327 <AnswerRow
328 key={m.seq}
329 message={m}
330 agent={names.get(m.agent)}
331 onLink={onLink}
332 onCopy={onCopy}
333 cite={preview.cite}
334 />
335 ),
336 )}
337 <div ref={end} />
338 </div>
339 <div className="ag-composer">
340 {offered.length > 0 && (
341 <ul className="ag-offer">
342 {offered.map((a) => (
343 <li key={a.id}>
344 <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => complete(a.id)}>
345 @{a.id} <span className="ag-muted">{a.name}</span>
346 </button>
347 </li>
348 ))}
349 </ul>
350 )}
351 {note && <div className="ag-note ag-muted">{note}</div>}
352 <div className="ag-compose-box">
353 <textarea
354 ref={box}
355 className="ag-input"
356 rows={2}
357 placeholder="@agent your question"
358 value={draft}
359 onChange={(e) => {
360 onDraft(e.target.value)
361 setCaret(e.target.selectionStart)
362 }}
363 onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
364 onKeyDown={(e) => {
365 if (e.key === 'Tab' && offered.length > 0) {
366 e.preventDefault()
367 complete(offered[0]!.id)
368 } else if (e.key === 'Enter' && !e.shiftKey) {
369 e.preventDefault()
370 send()
371 }
372 }}
373 />
374 <div className="ag-compose-tools">
375 <Attach docs={docs} onFiles={onFiles} onDelete={onDeleteDoc} />
376 <span className="ag-spacer" />
377 {working && <StopButton label="Stop the answers" onClick={onStop} />}
378 <Mic
379 onText={(text) => {
380 const before = draft.trimEnd()
381 onDraft(before ? `${before} ${text}` : text)
382 box.current?.focus()
383 }}
384 />
385 </div>
386 </div>
387 </div>
388 {preview.shown && (
389 <DocWindow
390 passage={preview.shown.passage}
391 pinned={preview.shown.pinned}
392 onPin={preview.togglePin}
393 onClose={preview.close}
394 onEnter={preview.hold}
395 onLeave={preview.release}
396 onLink={onLink}
397 />
398 )}
399 </>
400 )
401}
402
403/** How long a pointer rests on a citation before its document opens, and how long it may leave before it closes. */
404const OPEN_MS = 250
405const CLOSE_MS = 350
406
407/**
408 * The document window's state: one passage shown at a time. A citation opens it after the pointer
409 * rests on it, a click pins it, and it closes once the pointer has left both the citation and the
410 * window, unless pinned.
411 */
412function usePreview() {
413 const [shown, setShown] = useState<{ passage: Passage; pinned: boolean } | null>(null)
414 const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
415 const later = (run: () => void, ms: number): void => {
416 if (timer.current) clearTimeout(timer.current)
417 timer.current = setTimeout(run, ms)
418 }
419 const hold = (): void => {
420 if (timer.current) clearTimeout(timer.current)
421 }
422 const release = (): void => later(() => setShown((now) => (now?.pinned ? now : null)), CLOSE_MS)
423 const close = (): void => {
424 hold()
425 setShown(null)
426 }
427 const togglePin = (): void => setShown((now) => (now ? { ...now, pinned: !now.pinned } : now))
428 const cite = (passage: Passage | undefined, ref: string, key: string): ReactNode =>
429 passage ? (
430 <button
431 key={key}
432 type="button"
433 className="ag-cite"
434 title={passage.label}
435 onMouseEnter={() => later(() => setShown((now) => (now?.pinned ? now : { passage, pinned: false })), OPEN_MS)}
436 onMouseLeave={release}
437 onClick={() => {
438 hold()
439 setShown({ passage, pinned: true })
440 }}
441 >
442 {ref}
443 </button>
444 ) : (
445 <span key={key} className="ag-cite ag-cite-missing" title="No passage by this ref">
446 {ref}?
447 </span>
448 )
449 return { shown, cite, hold, release, close, togglePin }
450}
451
452function AnswerRow({
453 message,
454 agent,
455 onLink,
456 onCopy,
457 cite,
458}: {
459 message: AgentMessage
460 agent: Agent | undefined
461 onLink: (href: string) => void
462 onCopy: (text: string) => Promise<void>
463 cite: (passage: Passage | undefined, ref: string, key: string) => ReactNode
464}): ReactElement {
465 const passages = message.searches.reduce((n, s) => n + s.passages.length, 0)
466 const byRef = useMemo(
467 () => new Map(message.searches.flatMap((s) => s.passages.map((p) => [p.ref, p]))),
468 [message.searches],
469 )
470 return (
471 <div className="ag-answer">
472 <div className="ag-head">
473 <span
474 className="ag-swatch"
475 style={{ background: agent?.color ?? 'var(--jaspers-muted-foreground, #737373)' }}
476 />
477 <span className="ag-name">{agent?.name ?? message.agent}</span>
478 <span className="ag-muted">
479 {message.status === 'working' || message.status === 'done' ? '' : message.status}
480 {message.usage.input > 0 &&
481 ` · ${message.usage.input.toLocaleString()} in · ${message.usage.output.toLocaleString()} out`}
482 </span>
483 </div>
484 {message.skills && message.skills.length > 0 && (
485 <div className="ag-skills ag-muted">
486 {message.skills.map((use, i) => (
487 <span key={i} className={use.error ? 'ag-error' : undefined} title={use.error ?? undefined}>
488 {i > 0 && ' · '}
489 {use.file ? `read ${use.skill}/${use.file}` : `loaded ${use.skill}`}
490 {use.error && ' (failed)'}
491 </span>
492 ))}
493 </div>
494 )}
495 {message.searches.length > 0 && (
496 <details className="ag-searches">
497 <summary className="ag-muted">
498 {message.searches.length} {message.searches.length === 1 ? 'search' : 'searches'} · {passages}{' '}
499 {passages === 1 ? 'passage' : 'passages'}
500 </summary>
501 {message.searches.map((s, i) => (
502 <div key={i} className="ag-search">
503 <div className="ag-query">
504 {s.tool === 'calculate' ? (
505 <span className="ag-muted">calculation</span>
506 ) : (
507 <>
508 {s.tool && <span className="ag-muted">{TOOL_LABELS[s.tool]} · </span>}
509 {s.url && <span className="ag-muted">{s.url} · </span>}“{s.query}”
510 </>
511 )}
512 {s.filters && Object.keys(s.filters).length > 0 && (
513 <span className="ag-muted"> · {filtersText(s.filters)}</span>
514 )}
515 {s.error && <span className="ag-error ag-text"> {s.error}</span>}
516 </div>
517 {s.results?.map((r) => (
518 <div key={r.url} className="ag-passage ag-line">
519 <a
520 href={r.url}
521 title={r.url}
522 onClick={(e) => {
523 e.preventDefault()
524 onLink(r.url)
525 }}
526 >
527 {r.title || r.url}
528 </a>
529 </div>
530 ))}
531 {s.passages.map((p) => (
532 <div key={p.ref} className="ag-passage">
533 <div className="ag-muted">
534 [{p.ref}] {p.label} · {p.score.toFixed(2)}
535 </div>
536 <div className="ag-text">{p.text}</div>
537 </div>
538 ))}
539 </div>
540 ))}
541 </details>
542 )}
543 {message.text && (
544 <Markdown source={message.text} onLink={onLink} cite={(ref, key) => cite(byRef.get(ref), ref, key)} />
545 )}
546 {message.audit?.unknown.length ? (
547 <div className="ag-error ag-audit">
548 Cites {message.audit.unknown.map((r) => `[${r}]`).join(', ')}, which no search gave.
549 </div>
550 ) : null}
551 {message.error && <div className="ag-error">{message.error}</div>}
552 {message.status === 'working' && (
553 <div className="ag-working">
554 {message.working?.thought && <div className="ag-thought">{message.working.thought}</div>}
555 <div className="ag-shimmer">{message.working?.step ?? 'Thinking…'}</div>
556 </div>
557 )}
558 {message.status === 'done' && message.text && (
559 <div className="ag-answer-foot">
560 <CopyButton
561 label="Copy answer"
562 onCopy={() =>
563 onCopy(
564 withSources(
565 message.text,
566 message.searches.flatMap((s) => s.passages),
567 ),
568 )
569 }
570 />
571 </div>
572 )}
573 </div>
574 )
575}
576
577/** Fields as the form holds them: lists as comma-separated text. */
578interface Form {
579 name: string
580 description: string
581 color: string
582 documents: string[]
583 forms: string
584 origins: string[]
585 since: string
586 instructions: string
587 web: boolean
588 skills: string[]
589}
590
591/** A skill as the editor offers it. */
592interface SkillChoice {
593 name: string
594 description: string
595 plugin: string | null
596}
597
598function Editor({
599 agent,
600 taken,
601 docs,
602 onSave,
603 onDelete,
604 onClose,
605}: {
606 agent: Agent | null
607 taken: string[]
608 docs: Doc[]
609 onSave: (input: unknown) => Promise<void>
610 onDelete: (id: string) => Promise<void>
611 onClose: () => void
612}): ReactElement {
613 const search = agent?.search ?? emptySearch()
614 const [form, setForm] = useState<Form>({
615 name: agent?.name ?? '',
616 description: agent?.description ?? '',
617 color: agent?.color ?? '',
618 documents: search.documents,
619 forms: search.forms.join(', '),
620 origins: search.origins,
621 since: search.since ?? '',
622 instructions: agent?.instructions ?? '',
623 web: agent?.web ?? false,
624 skills: agent?.skills ?? [],
625 })
626 const bridge = useBridge()
627 /** The app's skills; null until they are read. */
628 const [installed, setInstalled] = useState<SkillChoice[] | null>(null)
629 useEffect(() => {
630 let live = true
631 bridge.runSource('agents/skills', {}).then(
632 (result) => live && setInstalled(result.kind === 'text' ? (JSON.parse(result.text) as SkillChoice[]) : []),
633 () => live && setInstalled([]),
634 )
635 return () => {
636 live = false
637 }
638 }, [bridge])
639 // A skill ticked before and since removed from the app stays offered, so it can be unticked.
640 const skillOptions = [
641 ...(installed ?? []).map((s) => ({ value: s.name, label: s.name, title: s.description })),
642 ...form.skills
643 .filter((name) => installed !== null && !installed.some((s) => s.name === name))
644 .map((name) => ({
645 value: name,
646 label: `${name} (not installed)`,
647 title: 'The app has no skill by this name now.',
648 })),
649 ]
650 const set = (key: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [key]: e.target.value })
651 // What the user tags it with: a new agent's from its name, one already made keeps its own, which
652 // its conversations name it by.
653 const id = agent?.id ?? agentIdFrom(form.name, taken)
654
655 const save = (): void => {
656 void onSave({
657 id,
658 name: form.name,
659 description: form.description,
660 // Every agent answers on the app's model.
661 model: null,
662 color: form.color.trim() || null,
663 web: form.web,
664 skills: form.skills,
665 search: {
666 documents: form.documents,
667 forms: list(form.forms).map((f) => f.toUpperCase()),
668 origins: form.origins,
669 since: form.since.trim() || null,
670 },
671 instructions: form.instructions,
672 }).catch(() => {})
673 }
674
675 return (
676 <>
677 <div className="ag-bar">
678 <span className="ag-title">{agent ? `Edit ${agent.name}` : 'New agent'}</span>
679 <span className="ag-spacer" />
680 <button type="button" className="ag-link" onClick={onClose}>
681 Back to chat
682 </button>
683 </div>
684 <div className="ag-form">
685 <Field label="Name" hint={form.name.trim() ? `You tag it @${id}.` : undefined}>
686 <input value={form.name} onChange={set('name')} placeholder="Documents Analyst" />
687 </Field>
688 <Field label="Description">
689 <input value={form.description} onChange={set('description')} />
690 </Field>
691 <Field label="Instructions" hint="How it searches and answers.">
692 <textarea rows={8} value={form.instructions} onChange={set('instructions')} />
693 </Field>
694 <div className="ag-field">
695 <label className="ag-check">
696 <input type="checkbox" checked={form.web} onChange={(e) => setForm({ ...form, web: e.target.checked })} />
697 Can read web pages
698 </label>
699 <span className="ag-muted ag-hint">
700 Reads a public page into the store and gets its 3 most relevant passages; the page is kept in Documents.
701 </span>
702 </div>
703 {installed !== null && (
704 <Ticks
705 label="Skills"
706 hint={
707 skillOptions.length > 0
708 ? "It sees each ticked skill's name and description, and loads one's instructions when a question calls for it."
709 : 'No skills yet. Add one in Settings, under Skills.'
710 }
711 empty="none"
712 options={skillOptions}
713 selected={form.skills}
714 onChange={(skills) => setForm({ ...form, skills })}
715 />
716 )}
717 <details className="ag-field ag-fold">
718 <summary>
719 <span className="ag-label">Advanced</span>
720 </summary>
721 <div className="ag-fold-body">
722 <div className="ag-section ag-muted">
723 What it reads: every company's filings, and the documents ticked; none ticked means all of them. The rest
724 narrow that.
725 </div>
726 {docs.length > 0 && (
727 <Ticks
728 label="Documents"
729 hint="Uploads and pages read in."
730 empty="none ticked"
731 options={docs.map((doc) => ({ value: doc.id, label: doc.name }))}
732 selected={form.documents}
733 onChange={(documents) => setForm({ ...form, documents })}
734 />
735 )}
736 <Fold label="Forms" chosen={list(form.forms).join(', ') || 'any'}>
737 <input value={form.forms} onChange={set('forms')} placeholder="10-K, 10-Q" />
738 <span className="ag-muted ag-hint">Comma-separated, like 10-K, 10-Q, 8-K; each with its amendments.</span>
739 </Fold>
740 <Ticks
741 label="Kinds"
742 hint="SEC filings, your uploads, web pages. None ticked means all."
743 empty="all"
744 options={ORIGINS.map((o) => ({
745 value: o,
746 label: o === 'sec' ? 'SEC filings' : o === 'upload' ? 'Uploads' : 'Web pages',
747 }))}
748 selected={form.origins}
749 onChange={(origins) => setForm({ ...form, origins })}
750 />
751 <Field label="Since" hint="Leave out what was filed or added before this date.">
752 <input value={form.since} onChange={set('since')} placeholder="2021-01-01" />
753 </Field>
754 <Field label="Color">
755 <input value={form.color} onChange={set('color')} placeholder="#1E3A5F" />
756 </Field>
757 </div>
758 </details>
759 <div className="ag-actions">
760 <button type="button" className="ag-button" onClick={save} disabled={!form.name.trim()}>
761 Save
762 </button>
763 {agent && (
764 <button type="button" className="ag-link ag-danger" onClick={() => void onDelete(agent.id).catch(() => {})}>
765 Delete
766 </button>
767 )}
768 </div>
769 </div>
770 </>
771 )
772}
773
774/** Values to tick in a comma-separated field, kept with whatever else the field holds. */
775/** A field folded shut until opened, its line saying what it holds, so the form reads at a glance. */
776function Fold({ label, chosen, children }: { label: string; chosen: string; children: ReactNode }): ReactElement {
777 return (
778 <details className="ag-field ag-fold">
779 <summary>
780 <span className="ag-label">{label}</span> <span className="ag-muted">· {chosen}</span>
781 </summary>
782 <div className="ag-fold-body">{children}</div>
783 </details>
784 )
785}
786
787/** Checkboxes in a fold whose line names what is ticked, or `empty` when nothing is. */
788function Ticks({
789 label,
790 hint,
791 empty,
792 options,
793 selected,
794 onChange,
795}: {
796 label: string
797 hint: string
798 empty: string
799 options: { value: string; label: string; title?: string }[]
800 selected: string[]
801 onChange: (selected: string[]) => void
802}): ReactElement {
803 const chosen = selected.map((value) => options.find((o) => o.value === value)?.label ?? value).join(', ')
804 return (
805 <Fold label={label} chosen={chosen || empty}>
806 <span className="ag-docs">
807 {options.map((option) => {
808 const on = selected.includes(option.value)
809 return (
810 <label key={option.value} className="ag-check" title={option.title}>
811 <input
812 type="checkbox"
813 checked={on}
814 onChange={() => onChange(on ? selected.filter((s) => s !== option.value) : [...selected, option.value])}
815 />
816 {option.label}
817 </label>
818 )
819 })}
820 </span>
821 <span className="ag-muted ag-hint">{hint}</span>
822 </Fold>
823 )
824}
825
826function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }): ReactElement {
827 return (
828 <label className="ag-field">
829 <span className="ag-label">{label}</span>
830 {children}
831 {hint && <span className="ag-muted ag-hint">{hint}</span>}
832 </label>
833 )
834}
835
836function list(text: string): string[] {
837 return text
838 .split(',')
839 .map((s) => s.trim())
840 .filter(Boolean)
841}
842
843/** The draft with @id added, unless it already tags it. */
844function withTag(draft: string, id: string): string {
845 if (new RegExp(`(^|\\s)@${id}(\\s|$)`).test(draft)) return draft
846 return `${draft.trimEnd()}${draft.trim() ? ' ' : ''}@${id} `
847}
848
849/** A file as the store takes it: its text alone (a PDF's page by page, a web page's without its markup). */
850async function readDoc(file: File): Promise<{ text: string }> {
851 if (isPdf(file.name)) return { text: await readPdf(new Uint8Array(await file.arrayBuffer()), file.name) }
852 const raw = await file.text()
853 if (!isMarkup(file.name)) return { text: raw }
854 const page = new DOMParser().parseFromString(raw, 'text/html')
855 for (const node of page.querySelectorAll('script, style, noscript, template')) node.remove()
856 // A parsed page is not laid out, so its text has no breaks of its own: blocks and cells get theirs here.
857 for (const cell of page.querySelectorAll('td, th')) cell.append('\t')
858 for (const block of page.querySelectorAll('p, div, br, li, tr, h1, h2, h3, h4, h5, h6, section, article, table')) {
859 block.append('\n')
860 }
861 const text = (page.body?.textContent ?? '')
862 .replace(/[ \t]+\n/g, '\n')
863 .replace(/\n{3,}/g, '\n\n')
864 .trim()
865 return { text }
866}
867
868/** A search's filters as a line: `10-K · FY2024 · Item 1A`. */
869/** How a search that is not of the store says what it was, before its words. */
870const TOOL_LABELS: Record<Exclude<Search['tool'], undefined>, string> = {
871 search_web: 'web',
872 add_company: 'filings',
873 calculate: 'calculation',
874}
875
876function filtersText(filters: Record<string, unknown>): string {
877 const show = (v: unknown): string => (Array.isArray(v) ? v.join(', ') : String(v))
878 return Object.entries(filters)
879 .filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0))
880 .map(([k, v]) => (k === 'fiscal_years' ? `FY${show(v)}` : k.startsWith('scope_') ? `in ${show(v)}` : show(v)))
881 .join(' · ')
882}
883
884function messageOf(err: unknown): string {
885 return err instanceof Error ? err.message : String(err)
886}