import { type ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { aceAvatar, type Attachment, Button, ChatComposer, type ChatComposerHandle, serialize, toast, useMedia, } from "@ace/ui"; import type { Listing, ModelRef } from "@ace/host/protocol"; import { host } from "./host"; import { images } from "./images"; const MODES = [ { id: "chat", name: "Chat", placeholder: "Mention @ace to ask the agent" }, { id: "ace", name: "Ace", placeholder: "Ask the agent", mention: "ace" }, ]; const MENTIONS = [{ name: "ace", avatar: aceAvatar }]; const ACE = /(^|\s)@ace\b/i; const key = (model: ModelRef) => `${model.provider}/${model.modelId}`; type Props = { channel: Listing; chat: number; user: string; current?: ModelRef; busy: boolean; ready: boolean; draft?: string; onDraftLoaded: () => void; onSettings?: () => void; accessory?: ReactNode; }; /** A failed admission keeps the draft and attachments available for retry. */ export function Composer( { channel, chat: id, user, current, busy, ready, draft, onDraftLoaded, onSettings, accessory }: Props, ) { const composer = useRef(null); const sending = useRef(false); const [pending, setPending] = useState(false); const [attached, setAttached] = useState([]); const [mode, setMode] = useState(draft || channel.model ? "ace" : "chat"); const [models, setModels] = useState(); const [modelError, setModelError] = useState(); const status = useSyncExternalStore(host.subscribe, () => host.status); const settingsVersion = useSyncExternalStore(host.subscribe, () => host.settingsVersion); useEffect(() => { if (!draft) return; composer.current?.set(ACE.test(draft) ? draft : `@ace ${draft}`); onDraftLoaded(); }, [draft, onDraftLoaded]); useEffect(() => { if (status !== "open" || mode !== "ace") return; let active = true; // Ask the channel itself: a hosted channel uses the service's credentials, not its workspace's. host.channel(channel.id, { op: "models" }).then( (models) => { if (!active) return; setModels(models); setModelError(undefined); }, (error: Error) => { if (!active) return; setModels([]); setModelError(error.message); }, ); return () => { active = false; }; }, [channel.id, mode, status, settingsVersion]); const [picked, setPicked] = useState(); const model = picked || (current ? key(current) : "auto"); const choices = (models || []).map((value) => ({ id: key(value), name: value.modelId, vendor: value.provider, })); if (model === "auto") choices.unshift({ id: "auto", name: "Automatic", vendor: "" }); else if (!choices.some((value) => value.id === model)) { choices.unshift({ id: model, name: model.split("/").slice(1).join("/"), vendor: "" }); } // Formatting tools crowd a phone's composer; they stay one tap away. const phone = useMedia("(width < 40rem)"); async function send(text: string, mode: string, attached: Attachment[]) { if (sending.current) return; const invoke = mode === "ace" || ACE.test(text); const body = text.trim(); if (!body && !attached.length) return; const doc = composer.current?.get(); sending.current = true; setPending(true); try { const encoded = attached.length ? { images: await images(attached) } : {}; if (invoke) { const [provider, ...rest] = model.split("/"); await host.channel(channel.id, { op: "ask", chat: id, author: user, text: body, ...encoded, ...(model === "auto" ? {} : { model: { provider: provider!, modelId: rest.join("/") } }), }); } else { await host.channel(channel.id, { op: "say", chat: id, author: user, text: body, ...encoded, }); } if (composer.current?.get() === doc) composer.current?.clear(); const sent = new Set(attached); setAttached((current) => current.filter((value) => !sent.has(value))); } catch (error) { toast.error(invoke ? "Could not invoke agent" : "Could not send message", { description: (error as Error).message, ...(invoke && onSettings ? { action: { label: "Settings", onClick: onSettings } } : {}), }); } finally { sending.current = false; setPending(false); } } return ( <> {mode === "ace" && models?.length === 0 && (

{modelError || "Add a provider to use Ace. You can still send messages in Chat mode."}

{onSettings && ( )}
)} setPicked(value === "auto" ? undefined : value)} attachments={attached} onAttachmentsChange={setAttached} busy={busy} submitBusy={pending} canStop={busy} onStop={() => void host.channel(channel.id, { op: "stop", chat: id })} canSend={ready && status === "open" && !pending} clearOnSend={false} onSend={({ doc, mode, attachments }) => void send(serialize(doc), mode, attachments)} /> ); }