import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react' import { useBridge, useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk' import { agentIdFrom, emptySearch, ORIGINS, type Agent } from './agents' import { companiesUsed, type AgentMessage, type Chat, type Message, type Passage, type Search } from './chat' import type { CompanyStatus } from './sec' import { CompanyChip, useCompanyCard } from './Company' import { AddButton, CopyButton, EditButton, IconButton, StopButton } from './buttons' import { withSources } from './cite' import { Attach } from './Attach' import { DocWindow } from './DocWindow' import { isMarkup, isPdf, type Doc } from './docs' import { Markdown } from './MarkdownView' import { Mic } from './Mic' import { readPdf } from './pdf' import { Setup } from './Setup' import type { Setup as SetupState } from './status' import './styles.css' // Each panel is one conversation: the agents on the left, and under them the companies the agents read // in during this conversation; the conversation on the right, its documents behind the paperclip // beside the message box. A panel placed anew starts a new conversation; its own conversation and // whether its side panel is folded are the panel's state, so a view comes back as it was. // A click on an agent tags it in the message being written; its pencil opens the form where the // conversation was. Typing @ offers the agents whose ids start with what follows. Files dropped // anywhere on the view, or picked with Upload, are read here as text and indexed by the plugin. /** The panel's state arrives a round trip after the frame mounts; nothing is opened until it has. */ export function AgentsView({ panel }: { panel: PanelRef }): ReactElement { const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as { conversation?: string; folded?: boolean; ask?: string } | undefined if (state === undefined) return
Loading…
return ( ) } function Agents({ panel, initial, initialFolded, initialAsk, }: { panel: PanelRef initial: string | undefined initialFolded: boolean initialAsk: string | undefined }): ReactElement { const bridge = useBridge() const agents = (useData('live/agents') as Agent[] | undefined) ?? [] const [conversation, setConversation] = usePanelState(panel, 'conversation', initial) const [folded, setFolded] = usePanelState(panel, 'folded', initialFolded) // A question the panel was placed with: posted once its conversation is open, then cleared. const [ask, setAsk] = usePanelState(panel, 'ask', initialAsk) const asked = useRef(false) const [open, setOpen] = useState(null) const chat = useData(`live/chat/${open ?? 'none'}`) as Chat | undefined const [editing, setEditing] = useState(null) const [draft, setDraft] = useState('') const docs = (useData('live/docs') as Doc[] | undefined) ?? [] const companies = (useData('live/sec') as CompanyStatus[] | undefined) ?? [] const setup = useData('live/setup') as SetupState | undefined const [failure, setFailure] = useState(null) const [note, setNote] = useState(null) const messages = open ? (chat?.messages ?? []) : [] const card = useCompanyCard() // The companies this conversation's agents touched, as the store has them now. const added = companiesUsed(messages).map( (ticker) => companies.find((c) => c.ticker === ticker) ?? { ticker, pending: true as const }, ) // The panel's conversation, or a new one for a panel placed anew: whichever the plugin opened is the state's. useEffect(() => { bridge .runSource('agents/open-conversation', conversation ? { id: conversation } : {}) .then((result) => { const id = result.kind === 'text' ? result.text : null setOpen(id) if (id && id !== conversation) setConversation(id) }) .catch((err: unknown) => setFailure(messageOf(err))) }, [bridge, conversation, setConversation]) usePublish(panel, { agents: agents.map((a) => a.id), messages: messages.length }) const run = (source: string, args: unknown): Promise => bridge.runSource(`agents/${source}`, args).then( () => setFailure(null), (err: unknown) => { setFailure(messageOf(err)) throw err }, ) useEffect(() => { if (!open || !ask?.trim() || asked.current) return asked.current = true void run('post', { conversation: open, text: ask }) .catch(() => {}) .finally(() => setAsk(undefined)) }, [open, ask]) /** One file at a time, so each says how it went. */ const upload = async (files: File[]): Promise => { for (const file of files) { setNote(`Indexing ${file.name}…`) try { const result = await bridge.runSource('agents/upload', { name: file.name, ...(await readDoc(file)), ...(open ? { conversation: open } : {}), }) setNote(result.kind === 'text' ? result.text : null) setFailure(null) } catch (err) { setNote(null) setFailure(messageOf(err)) } } } return (
e.preventDefault()} onDrop={(e) => { e.preventDefault() void upload([...e.dataTransfer.files]) }} > {folded ? ( ) : ( )}
{!setup?.signedIn ? ( ) : editing ? ( a.id)} docs={docs} onSave={(input) => run('save-agent', input).then(() => setEditing(null))} onDelete={(id) => run('delete-agent', { id }).then(() => setEditing(null))} onClose={() => setEditing(null)} /> ) : ( open ? run('post', { conversation: open, text }) : Promise.reject(new Error('Opening…')) } onStop={() => void (open && run('stop', { conversation: open }).catch(() => {}))} onLink={(href) => void bridge.openLink(href).catch(() => {})} onCopy={(text) => bridge.copyText(text)} docs={docs} note={note} onFiles={(files) => void upload(files)} onDeleteDoc={(id) => void run('delete-doc', { id }).catch(() => {})} /> )} {failure &&
{failure}
}
) } function SideToggle({ folded, onToggle }: { folded: boolean; onToggle: () => void }): ReactElement { return ( ) } function ChatPane({ agents, messages, draft, onDraft, onSend, onStop, onLink, onCopy, docs, note, onFiles, onDeleteDoc, }: { agents: Agent[] messages: Message[] draft: string onDraft: (text: string) => void onSend: (text: string) => Promise onStop: () => void onLink: (href: string) => void onCopy: (text: string) => Promise docs: Doc[] note: string | null onFiles: (files: File[]) => void onDeleteDoc: (id: string) => void }): ReactElement { const box = useRef(null) const end = useRef(null) const [caret, setCaret] = useState(0) const names = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]) const working = messages.some((m) => m.role === 'agent' && m.status === 'working') const partial = /(?:^|\s)@([a-z0-9-]*)$/i.exec(draft.slice(0, caret))?.[1]?.toLowerCase() const offered = partial === undefined ? [] : agents.filter((a) => a.error === null && a.id.startsWith(partial)) const preview = usePreview() // Braces: this Chromium's scrollIntoView returns a promise, which React would take for a cleanup. useEffect(() => { end.current?.scrollIntoView({ block: 'end' }) }, [messages]) const complete = (id: string): void => { const before = draft.slice(0, caret).replace(/@[a-z0-9-]*$/i, `@${id} `) onDraft(before + draft.slice(caret)) setCaret(before.length) box.current?.focus() } const send = (): void => { const text = draft.trim() if (!text) return onSend(text).then( () => onDraft(''), () => {}, ) } return ( <>
{messages.length === 0 && (
{agents.length === 0 ? '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.' : 'Tag an agent with @ and ask it something.'}
)} {messages.map((m) => m.role === 'user' ? (
{m.text}
) : ( ), )}
{offered.length > 0 && (
    {offered.map((a) => (
  • ))}
)} {note &&
{note}
}