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.

31 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 && <div className="ag-empty ag-muted">Tag an agent with @ and ask it something.</div>}
315 {messages.map((m) =>
316 m.role === 'user' ? (
317 <div key={m.seq} className="ag-user">
318 {m.text}
319 </div>
320 ) : (
321 <AnswerRow
322 key={m.seq}
323 message={m}
324 agent={names.get(m.agent)}
325 onLink={onLink}
326 onCopy={onCopy}
327 cite={preview.cite}
328 />
329 ),
330 )}
331 <div ref={end} />
332 </div>
333 <div className="ag-composer">
334 {offered.length > 0 && (
335 <ul className="ag-offer">
336 {offered.map((a) => (
337 <li key={a.id}>
338 <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => complete(a.id)}>
339 @{a.id} <span className="ag-muted">{a.name}</span>
340 </button>
341 </li>
342 ))}
343 </ul>
344 )}
345 {note && <div className="ag-note ag-muted">{note}</div>}
346 <div className="ag-compose-box">
347 <textarea
348 ref={box}
349 className="ag-input"
350 rows={2}
351 placeholder="@agent your question"
352 value={draft}
353 onChange={(e) => {
354 onDraft(e.target.value)
355 setCaret(e.target.selectionStart)
356 }}
357 onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
358 onKeyDown={(e) => {
359 if (e.key === 'Tab' && offered.length > 0) {
360 e.preventDefault()
361 complete(offered[0]!.id)
362 } else if (e.key === 'Enter' && !e.shiftKey) {
363 e.preventDefault()
364 send()
365 }
366 }}
367 />
368 <div className="ag-compose-tools">
369 <Attach docs={docs} onFiles={onFiles} onDelete={onDeleteDoc} />
370 <span className="ag-spacer" />
371 {working && <StopButton label="Stop the answers" onClick={onStop} />}
372 <Mic
373 onText={(text) => {
374 const before = draft.trimEnd()
375 onDraft(before ? `${before} ${text}` : text)
376 box.current?.focus()
377 }}
378 />
379 </div>
380 </div>
381 </div>
382 {preview.shown && (
383 <DocWindow
384 passage={preview.shown.passage}
385 pinned={preview.shown.pinned}
386 onPin={preview.togglePin}
387 onClose={preview.close}
388 onEnter={preview.hold}
389 onLeave={preview.release}
390 onLink={onLink}
391 />
392 )}
393 </>
394 )
395}
396
397/** How long a pointer rests on a citation before its document opens, and how long it may leave before it closes. */
398const OPEN_MS = 250
399const CLOSE_MS = 350
400
401/**
402 * The document window's state: one passage shown at a time. A citation opens it after the pointer
403 * rests on it, a click pins it, and it closes once the pointer has left both the citation and the
404 * window, unless pinned.
405 */
406function usePreview() {
407 const [shown, setShown] = useState<{ passage: Passage; pinned: boolean } | null>(null)
408 const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
409 const later = (run: () => void, ms: number): void => {
410 if (timer.current) clearTimeout(timer.current)
411 timer.current = setTimeout(run, ms)
412 }
413 const hold = (): void => {
414 if (timer.current) clearTimeout(timer.current)
415 }
416 const release = (): void => later(() => setShown((now) => (now?.pinned ? now : null)), CLOSE_MS)
417 const close = (): void => {
418 hold()
419 setShown(null)
420 }
421 const togglePin = (): void => setShown((now) => (now ? { ...now, pinned: !now.pinned } : now))
422 const cite = (passage: Passage | undefined, ref: string, key: string): ReactNode =>
423 passage ? (
424 <button
425 key={key}
426 type="button"
427 className="ag-cite"
428 title={passage.label}
429 onMouseEnter={() => later(() => setShown((now) => (now?.pinned ? now : { passage, pinned: false })), OPEN_MS)}
430 onMouseLeave={release}
431 onClick={() => {
432 hold()
433 setShown({ passage, pinned: true })
434 }}
435 >
436 {ref}
437 </button>
438 ) : (
439 <span key={key} className="ag-cite ag-cite-missing" title="No passage by this ref">
440 {ref}?
441 </span>
442 )
443 return { shown, cite, hold, release, close, togglePin }
444}
445
446function AnswerRow({
447 message,
448 agent,
449 onLink,
450 onCopy,
451 cite,
452}: {
453 message: AgentMessage
454 agent: Agent | undefined
455 onLink: (href: string) => void
456 onCopy: (text: string) => Promise<void>
457 cite: (passage: Passage | undefined, ref: string, key: string) => ReactNode
458}): ReactElement {
459 const passages = message.searches.reduce((n, s) => n + s.passages.length, 0)
460 const byRef = useMemo(
461 () => new Map(message.searches.flatMap((s) => s.passages.map((p) => [p.ref, p]))),
462 [message.searches],
463 )
464 return (
465 <div className="ag-answer">
466 <div className="ag-head">
467 <span
468 className="ag-swatch"
469 style={{ background: agent?.color ?? 'var(--jaspers-muted-foreground, #737373)' }}
470 />
471 <span className="ag-name">{agent?.name ?? message.agent}</span>
472 <span className="ag-muted">
473 {message.status === 'working' || message.status === 'done' ? '' : message.status}
474 {message.usage.input > 0 &&
475 ` · ${message.usage.input.toLocaleString()} in · ${message.usage.output.toLocaleString()} out`}
476 </span>
477 </div>
478 {message.skills && message.skills.length > 0 && (
479 <div className="ag-skills ag-muted">
480 {message.skills.map((use, i) => (
481 <span key={i} className={use.error ? 'ag-error' : undefined} title={use.error ?? undefined}>
482 {i > 0 && ' · '}
483 {use.file ? `read ${use.skill}/${use.file}` : `loaded ${use.skill}`}
484 {use.error && ' (failed)'}
485 </span>
486 ))}
487 </div>
488 )}
489 {message.searches.length > 0 && (
490 <details className="ag-searches">
491 <summary className="ag-muted">
492 {message.searches.length} {message.searches.length === 1 ? 'search' : 'searches'} · {passages}{' '}
493 {passages === 1 ? 'passage' : 'passages'}
494 </summary>
495 {message.searches.map((s, i) => (
496 <div key={i} className="ag-search">
497 <div className="ag-query">
498 {s.tool === 'calculate' ? (
499 <span className="ag-muted">calculation</span>
500 ) : (
501 <>
502 {s.tool && <span className="ag-muted">{TOOL_LABELS[s.tool]} · </span>}
503 {s.url && <span className="ag-muted">{s.url} · </span>}“{s.query}”
504 </>
505 )}
506 {s.filters && Object.keys(s.filters).length > 0 && (
507 <span className="ag-muted"> · {filtersText(s.filters)}</span>
508 )}
509 {s.error && <span className="ag-error ag-text"> {s.error}</span>}
510 </div>
511 {s.results?.map((r) => (
512 <div key={r.url} className="ag-passage ag-line">
513 <a
514 href={r.url}
515 title={r.url}
516 onClick={(e) => {
517 e.preventDefault()
518 onLink(r.url)
519 }}
520 >
521 {r.title || r.url}
522 </a>
523 </div>
524 ))}
525 {s.passages.map((p) => (
526 <div key={p.ref} className="ag-passage">
527 <div className="ag-muted">
528 [{p.ref}] {p.label} · {p.score.toFixed(2)}
529 </div>
530 <div className="ag-text">{p.text}</div>
531 </div>
532 ))}
533 </div>
534 ))}
535 </details>
536 )}
537 {message.text && (
538 <Markdown source={message.text} onLink={onLink} cite={(ref, key) => cite(byRef.get(ref), ref, key)} />
539 )}
540 {message.audit?.unknown.length ? (
541 <div className="ag-error ag-audit">
542 Cites {message.audit.unknown.map((r) => `[${r}]`).join(', ')}, which no search gave.
543 </div>
544 ) : null}
545 {message.error && <div className="ag-error">{message.error}</div>}
546 {message.status === 'working' && (
547 <div className="ag-working">
548 {message.working?.thought && <div className="ag-thought">{message.working.thought}</div>}
549 <div className="ag-shimmer">{message.working?.step ?? 'Thinking…'}</div>
550 </div>
551 )}
552 {message.status === 'done' && message.text && (
553 <div className="ag-answer-foot">
554 <CopyButton
555 label="Copy answer"
556 onCopy={() =>
557 onCopy(
558 withSources(
559 message.text,
560 message.searches.flatMap((s) => s.passages),
561 ),
562 )
563 }
564 />
565 </div>
566 )}
567 </div>
568 )
569}
570
571/** Fields as the form holds them: lists as comma-separated text. */
572interface Form {
573 name: string
574 description: string
575 color: string
576 documents: string[]
577 forms: string
578 origins: string[]
579 since: string
580 instructions: string
581 web: boolean
582 skills: string[]
583}
584
585/** A skill as the editor offers it. */
586interface SkillChoice {
587 name: string
588 description: string
589 plugin: string | null
590}
591
592function Editor({
593 agent,
594 taken,
595 docs,
596 onSave,
597 onDelete,
598 onClose,
599}: {
600 agent: Agent | null
601 taken: string[]
602 docs: Doc[]
603 onSave: (input: unknown) => Promise<void>
604 onDelete: (id: string) => Promise<void>
605 onClose: () => void
606}): ReactElement {
607 const search = agent?.search ?? emptySearch()
608 const [form, setForm] = useState<Form>({
609 name: agent?.name ?? '',
610 description: agent?.description ?? '',
611 color: agent?.color ?? '',
612 documents: search.documents,
613 forms: search.forms.join(', '),
614 origins: search.origins,
615 since: search.since ?? '',
616 instructions: agent?.instructions ?? '',
617 web: agent?.web ?? false,
618 skills: agent?.skills ?? [],
619 })
620 const bridge = useBridge()
621 /** The app's skills; null until they are read. */
622 const [installed, setInstalled] = useState<SkillChoice[] | null>(null)
623 useEffect(() => {
624 let live = true
625 bridge.runSource('agents/skills', {}).then(
626 (result) => live && setInstalled(result.kind === 'text' ? (JSON.parse(result.text) as SkillChoice[]) : []),
627 () => live && setInstalled([]),
628 )
629 return () => {
630 live = false
631 }
632 }, [bridge])
633 // A skill ticked before and since removed from the app stays offered, so it can be unticked.
634 const skillOptions = [
635 ...(installed ?? []).map((s) => ({ value: s.name, label: s.name, title: s.description })),
636 ...form.skills
637 .filter((name) => installed !== null && !installed.some((s) => s.name === name))
638 .map((name) => ({
639 value: name,
640 label: `${name} (not installed)`,
641 title: 'The app has no skill by this name now.',
642 })),
643 ]
644 const set = (key: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [key]: e.target.value })
645 // What the user tags it with: a new agent's from its name, one already made keeps its own, which
646 // its conversations name it by.
647 const id = agent?.id ?? agentIdFrom(form.name, taken)
648
649 const save = (): void => {
650 void onSave({
651 id,
652 name: form.name,
653 description: form.description,
654 // Every agent answers on the app's model.
655 model: null,
656 color: form.color.trim() || null,
657 web: form.web,
658 skills: form.skills,
659 search: {
660 documents: form.documents,
661 forms: list(form.forms).map((f) => f.toUpperCase()),
662 origins: form.origins,
663 since: form.since.trim() || null,
664 },
665 instructions: form.instructions,
666 }).catch(() => {})
667 }
668
669 return (
670 <>
671 <div className="ag-bar">
672 <span className="ag-title">{agent ? `Edit ${agent.name}` : 'New agent'}</span>
673 <span className="ag-spacer" />
674 <button type="button" className="ag-link" onClick={onClose}>
675 Back to chat
676 </button>
677 </div>
678 <div className="ag-form">
679 <Field label="Name" hint={form.name.trim() ? `You tag it @${id}.` : undefined}>
680 <input value={form.name} onChange={set('name')} placeholder="Documents Analyst" />
681 </Field>
682 <Field label="Description">
683 <input value={form.description} onChange={set('description')} />
684 </Field>
685 <Field label="Instructions" hint="How it searches and answers.">
686 <textarea rows={8} value={form.instructions} onChange={set('instructions')} />
687 </Field>
688 <div className="ag-field">
689 <label className="ag-check">
690 <input type="checkbox" checked={form.web} onChange={(e) => setForm({ ...form, web: e.target.checked })} />
691 Can read web pages
692 </label>
693 <span className="ag-muted ag-hint">
694 Reads a public page into the store and gets its 3 most relevant passages; the page is kept in Documents.
695 </span>
696 </div>
697 {installed !== null && (
698 <Ticks
699 label="Skills"
700 hint={
701 skillOptions.length > 0
702 ? "It sees each ticked skill's name and description, and loads one's instructions when a question calls for it."
703 : 'No skills yet. Add one in Settings, under Skills.'
704 }
705 empty="none"
706 options={skillOptions}
707 selected={form.skills}
708 onChange={(skills) => setForm({ ...form, skills })}
709 />
710 )}
711 <details className="ag-field ag-fold">
712 <summary>
713 <span className="ag-label">Advanced</span>
714 </summary>
715 <div className="ag-fold-body">
716 <div className="ag-section ag-muted">
717 What it reads: every company's filings, and the documents ticked; none ticked means all of them. The rest
718 narrow that.
719 </div>
720 {docs.length > 0 && (
721 <Ticks
722 label="Documents"
723 hint="Uploads and pages read in."
724 empty="none ticked"
725 options={docs.map((doc) => ({ value: doc.id, label: doc.name }))}
726 selected={form.documents}
727 onChange={(documents) => setForm({ ...form, documents })}
728 />
729 )}
730 <Fold label="Forms" chosen={list(form.forms).join(', ') || 'any'}>
731 <input value={form.forms} onChange={set('forms')} placeholder="10-K, 10-Q" />
732 <span className="ag-muted ag-hint">Comma-separated, like 10-K, 10-Q, 8-K; each with its amendments.</span>
733 </Fold>
734 <Ticks
735 label="Kinds"
736 hint="SEC filings, your uploads, web pages. None ticked means all."
737 empty="all"
738 options={ORIGINS.map((o) => ({
739 value: o,
740 label: o === 'sec' ? 'SEC filings' : o === 'upload' ? 'Uploads' : 'Web pages',
741 }))}
742 selected={form.origins}
743 onChange={(origins) => setForm({ ...form, origins })}
744 />
745 <Field label="Since" hint="Leave out what was filed or added before this date.">
746 <input value={form.since} onChange={set('since')} placeholder="2021-01-01" />
747 </Field>
748 <Field label="Color">
749 <input value={form.color} onChange={set('color')} placeholder="#1E3A5F" />
750 </Field>
751 </div>
752 </details>
753 <div className="ag-actions">
754 <button type="button" className="ag-button" onClick={save} disabled={!form.name.trim()}>
755 Save
756 </button>
757 {agent && (
758 <button type="button" className="ag-link ag-danger" onClick={() => void onDelete(agent.id).catch(() => {})}>
759 Delete
760 </button>
761 )}
762 </div>
763 </div>
764 </>
765 )
766}
767
768/** Values to tick in a comma-separated field, kept with whatever else the field holds. */
769/** A field folded shut until opened, its line saying what it holds, so the form reads at a glance. */
770function Fold({ label, chosen, children }: { label: string; chosen: string; children: ReactNode }): ReactElement {
771 return (
772 <details className="ag-field ag-fold">
773 <summary>
774 <span className="ag-label">{label}</span> <span className="ag-muted">· {chosen}</span>
775 </summary>
776 <div className="ag-fold-body">{children}</div>
777 </details>
778 )
779}
780
781/** Checkboxes in a fold whose line names what is ticked, or `empty` when nothing is. */
782function Ticks({
783 label,
784 hint,
785 empty,
786 options,
787 selected,
788 onChange,
789}: {
790 label: string
791 hint: string
792 empty: string
793 options: { value: string; label: string; title?: string }[]
794 selected: string[]
795 onChange: (selected: string[]) => void
796}): ReactElement {
797 const chosen = selected.map((value) => options.find((o) => o.value === value)?.label ?? value).join(', ')
798 return (
799 <Fold label={label} chosen={chosen || empty}>
800 <span className="ag-docs">
801 {options.map((option) => {
802 const on = selected.includes(option.value)
803 return (
804 <label key={option.value} className="ag-check" title={option.title}>
805 <input
806 type="checkbox"
807 checked={on}
808 onChange={() => onChange(on ? selected.filter((s) => s !== option.value) : [...selected, option.value])}
809 />
810 {option.label}
811 </label>
812 )
813 })}
814 </span>
815 <span className="ag-muted ag-hint">{hint}</span>
816 </Fold>
817 )
818}
819
820function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }): ReactElement {
821 return (
822 <label className="ag-field">
823 <span className="ag-label">{label}</span>
824 {children}
825 {hint && <span className="ag-muted ag-hint">{hint}</span>}
826 </label>
827 )
828}
829
830function list(text: string): string[] {
831 return text
832 .split(',')
833 .map((s) => s.trim())
834 .filter(Boolean)
835}
836
837/** The draft with @id added, unless it already tags it. */
838function withTag(draft: string, id: string): string {
839 if (new RegExp(`(^|\\s)@${id}(\\s|$)`).test(draft)) return draft
840 return `${draft.trimEnd()}${draft.trim() ? ' ' : ''}@${id} `
841}
842
843/** A file as the store takes it: its text alone (a PDF's page by page, a web page's without its markup). */
844async function readDoc(file: File): Promise<{ text: string }> {
845 if (isPdf(file.name)) return { text: await readPdf(new Uint8Array(await file.arrayBuffer()), file.name) }
846 const raw = await file.text()
847 if (!isMarkup(file.name)) return { text: raw }
848 const page = new DOMParser().parseFromString(raw, 'text/html')
849 for (const node of page.querySelectorAll('script, style, noscript, template')) node.remove()
850 // A parsed page is not laid out, so its text has no breaks of its own: blocks and cells get theirs here.
851 for (const cell of page.querySelectorAll('td, th')) cell.append('\t')
852 for (const block of page.querySelectorAll('p, div, br, li, tr, h1, h2, h3, h4, h5, h6, section, article, table')) {
853 block.append('\n')
854 }
855 const text = (page.body?.textContent ?? '')
856 .replace(/[ \t]+\n/g, '\n')
857 .replace(/\n{3,}/g, '\n\n')
858 .trim()
859 return { text }
860}
861
862/** A search's filters as a line: `10-K · FY2024 · Item 1A`. */
863/** How a search that is not of the store says what it was, before its words. */
864const TOOL_LABELS: Record<Exclude<Search['tool'], undefined>, string> = {
865 search_web: 'web',
866 add_company: 'filings',
867 calculate: 'calculation',
868}
869
870function filtersText(filters: Record<string, unknown>): string {
871 const show = (v: unknown): string => (Array.isArray(v) ? v.join(', ') : String(v))
872 return Object.entries(filters)
873 .filter(([, v]) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0))
874 .map(([k, v]) => (k === 'fiscal_years' ? `FY${show(v)}` : k.startsWith('scope_') ? `in ${show(v)}` : show(v)))
875 .join(' · ')
876}
877
878function messageOf(err: unknown): string {
879 return err instanceof Error ? err.message : String(err)
880}