diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index 5b7ddef90..20288d878 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -67,6 +67,8 @@ export const deTranslations = { 'aiComponents.conversation.scrollToLatest.ariaLabel': 'Zur neuesten Nachricht scrollen', 'aiComponents.messageAttachment.file.text': 'Datei', 'aiComponents.messageAttachment.image.text': 'Bild', + 'aiComponents.messageComposer.cancelEditing.ariaLabel': 'Bearbeiten abbrechen', + 'aiComponents.messageComposer.editing.text': 'Wird bearbeitet', 'aiComponents.messageComposer.speechToText.ariaLabel': 'Spracheingabe starten', 'aiComponents.messageComposer.textInput.placeholder': 'Stelle eine Frage...', 'aiComponents.messageParts.unsupportedStep.text': diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index ea4dd4f6f..3b4af1a8f 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -58,6 +58,8 @@ export const itTranslations = { 'aiComponents.conversation.scrollToLatest.ariaLabel': "Vai all'ultimo messaggio", 'aiComponents.messageAttachment.file.text': 'File', 'aiComponents.messageAttachment.image.text': 'Immagine', + 'aiComponents.messageComposer.cancelEditing.ariaLabel': 'Annulla modifica', + 'aiComponents.messageComposer.editing.text': 'In modifica', 'aiComponents.messageComposer.speechToText.ariaLabel': 'Avvia input vocale', 'aiComponents.messageComposer.textInput.placeholder': 'Fai una domanda...', 'aiComponents.messageParts.unsupportedStep.text': diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index a7a179b20..25638555f 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -41,6 +41,8 @@ "aiComponents.conversation.scrollToLatest.ariaLabel": "Scroll to latest", "aiComponents.messageAttachment.file.text": "File", "aiComponents.messageAttachment.image.text": "Image", + "aiComponents.messageComposer.cancelEditing.ariaLabel": "Cancel editing", + "aiComponents.messageComposer.editing.text": "Editing", "aiComponents.messageComposer.speechToText.ariaLabel": "Start voice input", "aiComponents.messageComposer.textInput.placeholder": "Ask a question...", "aiComponents.messageParts.unsupportedStep.text": "A step this app version can't show", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index d4e3249c2..b9d079faa 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -52,6 +52,8 @@ export type TranslationCatalog = { 'aiComponents.conversation.scrollToLatest.ariaLabel': 'Scroll to latest'; 'aiComponents.messageAttachment.file.text': 'File'; 'aiComponents.messageAttachment.image.text': 'Image'; + 'aiComponents.messageComposer.cancelEditing.ariaLabel': 'Cancel editing'; + 'aiComponents.messageComposer.editing.text': 'Editing'; 'aiComponents.messageComposer.speechToText.ariaLabel': 'Start voice input'; 'aiComponents.messageComposer.textInput.placeholder': 'Ask a question...'; 'aiComponents.messageParts.unsupportedStep.text': "A step this app version can't show"; diff --git a/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx b/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx index c0839ebbc..5994a2113 100644 --- a/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx +++ b/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx @@ -25,6 +25,17 @@ import { ChatOptions as ChatOptionsBase, type ChatOptionsProps } from './ChatOpt import { useSpeechToText, type UseSpeechToTextOptions } from './hooks/useSpeechToText'; import { useIsomorphicLayoutEffect } from '../utils'; import { ThemeScopeProvider, useAIThemeScopeClassName } from '../themeScope'; +import type { + AIConversationController, + AIConversationControllerState, +} from '../Conversation/controller/AIConversationController'; +import { isAIConversationAnswering } from '../Conversation/controller/types'; +import { + type AIMessageComposerStore, + initialAIMessageComposerState as initialStoreState, +} from './store'; + +export type { AIMessageComposerStore } from './store'; const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 15); @@ -93,29 +104,29 @@ const WithStableId = ({ FileInput.WithStableId = WithStableId; -export type AIMessageComposerStore = { - attachments: { - file: File; - id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- public type kept identical to @stream-io/chat-react-ai@0.2.0 for drop-in migration - meta?: Record; - }[]; - text: string; - disabled?: boolean; -}; - -const initialStoreState: AIMessageComposerStore = { - attachments: [], - disabled: false, - text: '', -}; - const AIMessageComposerContext = createContext>( new StateStore(initialStoreState), ); export const useAIMessageComposerContext = () => useContext(AIMessageComposerContext); +// The conversation the composer writes in, if it was given one. +const AIMessageComposerConversationContext = createContext< + AIConversationController | undefined +>(undefined); + +const statusSelector = ({ status }: AIConversationControllerState) => ({ status }); + +// Whether the agent of the composer's conversation is answering. `false` without a conversation. +const useIsAnswering = (conversation: AIConversationController | undefined) => { + const { status } = useStateStore(conversation?.state, statusSelector) ?? {}; + return !!status && isAIConversationAnswering(status); +}; + +const isEditingSelector = (currentState: AIMessageComposerStore) => ({ + isEditing: !!currentState.isEditing, +}); + export const useAttachments = () => { const store = useAIMessageComposerContext(); @@ -254,6 +265,13 @@ export const useIsDisabled = () => { export type AIMessageComposerLayout = 'stacked' | 'inline'; type AIMessageComposerProps = ComponentPropsWithoutRef<'form'> & { + /** + * The conversation the person writes in. The composer shows its `composer` store, and + * submitting sends what the person wrote through it (`conversation.send`), unless `onSubmit` + * calls `event.preventDefault()`. Meanwhile `SubmitButton` turns into a `StopButton`, and + * while the person edits a message the composer says so, with a button that cancels it. + */ + conversation?: AIConversationController; /** * How the composer arranges its parts: one under another, or the text and the controls on * one line, which suits a composer whose only control is the send button. @@ -293,18 +311,21 @@ interface AIMessageComposer { export const AIMessageComposer: AIMessageComposer = ({ children, className, + conversation, disabled, layout = 'stacked', nameMapping, onChange, onReset, + onSubmit, resetAttachmentsOnSelect = true, ...restProps }) => { const themeScopeClassName = useAIThemeScopeClassName(); - const [stateStore] = useState( + const [ownStore] = useState( () => new StateStore(initialStoreState), ); + const stateStore = conversation?.composer ?? ownStore; useEffect(() => { stateStore.partialNext({ disabled }); @@ -361,33 +382,104 @@ export const AIMessageComposer: AIMessageComposer = ({ const handleReset = useStableCallback((e: React.FormEvent) => { onReset?.(e); - // a reset clears the draft but must not re-enable a composer disabled via props - stateStore.next({ ...initialStoreState, disabled }); + // a reset clears the draft but must not re-enable a composer disabled via props, and the + // chat option stays picked + stateStore.next((currentState) => ({ + ...initialStoreState, + chatOption: currentState.chatOption, + disabled, + })); + }); + + const handleSubmit = useStableCallback((e: FormSubmitEvent) => { + onSubmit?.(e); + if (!conversation || e.defaultPrevented) return; + e.preventDefault(); + // Enter while the agent answers doesn't send: the send button is a stop button meanwhile. + if (isAIConversationAnswering(conversation.state.getLatestValue().status)) return; + const { attachments, chatOption, custom, text } = stateStore.getLatestValue(); + if (text.trim() === '' && attachments.length === 0) return; + conversation.send({ + attachments: attachments.map(({ file }) => file), + chatOption: chatOption ?? undefined, + custom, + text, + }); + stateStore.partialNext({ attachments: [], text: '' }); }); return ( -
- {children} -
+ +
+ + {children} + +
); }; +type FormSubmitEvent = Parameters< + NonNullable['onSubmit']> +>[0]; + const noop = () => undefined; +/** + * Says the person is editing a message they sent, with a button that cancels it, while the + * composer's store says they are (`isEditing`). + */ +const EditingIndicator = () => { + const store = useAIMessageComposerContext(); + const conversation = useContext(AIMessageComposerConversationContext); + const { isEditing } = useStateStore(store, isEditingSelector); + const { t } = useTranslationContext(); + const { IconEdit, IconXmarkSmall } = useComponentContextIcons(); + if (!isEditing) return null; + const cancel = () => { + conversation?.cancelEditing(); + if (store.getLatestValue().isEditing) + store.partialNext({ isEditing: false, text: '' }); + }; + return ( +
+ + + + + + {t('aiComponents.messageComposer.editing.text', 'Editing')} + + + +
+ ); +}; + const useHasMounted = () => { const [hasMounted, setHasMounted] = useState(false); useEffect(() => setHasMounted(true), []); @@ -413,6 +505,15 @@ const TextInput = ({ onKeyDown, ...props }: ComponentPropsWithoutRef<'textarea'> const { disabled } = useIsDisabled(); const { t } = useTranslationContext(); const ref = useRef(null); + const { isEditing } = useStateStore(useAIMessageComposerContext(), isEditingSelector); + + // Editing a message puts its text here: the person goes on writing at its end. + useEffect(() => { + const element = ref.current; + if (!isEditing || !element) return; + element.focus(); + element.setSelectionRange(element.value.length, element.value.length); + }, [isEditing]); // Sized to its lines: each time the text changes, and each time its width does, which wraps // them differently (a resized window, or a composer first laid out while hidden). @@ -529,6 +630,10 @@ const SpeechToTextButton = ({ ); }; +/** + * Sends the message. In a composer with a `conversation`, a `StopButton` takes its place while + * the agent answers. + */ const SubmitButton = ({ active, ...restProps @@ -536,6 +641,9 @@ const SubmitButton = ({ const { disabled } = useIsDisabled(); const { t } = useTranslationContext(); const { IconSend } = useComponentContextIcons(); + const conversation = useContext(AIMessageComposerConversationContext); + const isAnswering = useIsAnswering(conversation); + if (isAnswering) return ; return ( @@ -586,10 +706,49 @@ const Actions = ({ className, ...restProps }: ComponentPropsWithoutRef<'div'>) = /> ); -// Follows the composer's disabled state unless told otherwise. -const ChatOptions = (props: ChatOptionsProps) => { +const chatOptionSelector = (currentState: AIMessageComposerStore) => ({ + chatOption: currentState.chatOption, +}); + +// Follows the composer's disabled state unless told otherwise, and keeps the picked option in +// the composer's store (`chatOption`), where a conversation finds it when the message is sent. +const ChatOptions = ({ + defaultSelectedId, + onSelectedChange, + options, + selectedId, + ...props +}: ChatOptionsProps) => { + const store = useAIMessageComposerContext(); const { disabled } = useIsDisabled(); - return ; + const { chatOption } = useStateStore(store, chatOptionSelector); + // Picked before anything is: the default, or the controlled option, which the store takes. + const isControlled = selectedId !== undefined; + const seededId = isControlled ? selectedId : (defaultSelectedId ?? null); + const shownId = + isControlled || chatOption === undefined ? seededId : (chatOption?.id ?? null); + useIsomorphicLayoutEffect(() => { + const current = store.getLatestValue().chatOption; + if (!isControlled && current !== undefined) return; + const option = options.find((item) => item.id === seededId) ?? null; + // By id: an app's options can be new objects on every render. + if (current === undefined || (current?.id ?? null) !== (option?.id ?? null)) { + store.partialNext({ chatOption: option }); + } + }, [isControlled, options, seededId, store]); + + return ( + { + if (!isControlled) store.partialNext({ chatOption: option }); + onSelectedChange?.(option); + }} + options={options} + selectedId={isControlled ? selectedId : shownId} + /> + ); }; const availableModels = [ diff --git a/src/plugins/AIComponents/AIMessageComposer/index.ts b/src/plugins/AIComponents/AIMessageComposer/index.ts index 9ed4fd50a..803126daf 100644 --- a/src/plugins/AIComponents/AIMessageComposer/index.ts +++ b/src/plugins/AIComponents/AIMessageComposer/index.ts @@ -1,5 +1,6 @@ export * from './AIMessageComposer'; export * from './hooks/useSpeechToText'; +export { createAIMessageComposerStore } from './store'; export { type AIChatOption, ChatOptionsList, diff --git a/src/plugins/AIComponents/AIMessageComposer/store.ts b/src/plugins/AIComponents/AIMessageComposer/store.ts new file mode 100644 index 000000000..89ca4bd24 --- /dev/null +++ b/src/plugins/AIComponents/AIMessageComposer/store.ts @@ -0,0 +1,36 @@ +import { StateStore } from '@stream-io/state-store'; + +import type { AIChatOption } from './ChatOptions'; + +export type AIMessageComposerStore = { + attachments: { + file: File; + id: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- public type kept identical to @stream-io/chat-react-ai@0.2.0 for drop-in migration + meta?: Record; + }[]; + text: string; + /** + * The chat option picked for the next message, which `AIMessageComposer.ChatOptions` shows. It + * stays picked after a message is sent. + */ + chatOption?: AIChatOption | null; + /** Data of your own, which an `AIConversationController` sends with the message as `custom`. */ + custom?: Record; + disabled?: boolean; + /** + * Whether the person is editing a message they sent. The composer says so above the text, + * with a button that cancels it, and focuses the text input. + */ + isEditing?: boolean; +}; + +export const initialAIMessageComposerState: AIMessageComposerStore = { + attachments: [], + disabled: false, + text: '', +}; + +/** A store for what the person writes in an `AIMessageComposer`, such as for a conversation. */ +export const createAIMessageComposerStore = () => + new StateStore(initialAIMessageComposerState); diff --git a/src/plugins/AIComponents/Conversation/AIConversation.tsx b/src/plugins/AIComponents/Conversation/AIConversation.tsx index f724482b1..874b6a576 100644 --- a/src/plugins/AIComponents/Conversation/AIConversation.tsx +++ b/src/plugins/AIComponents/Conversation/AIConversation.tsx @@ -30,6 +30,12 @@ import { } from './entries'; import type { AIMessage, AIPendingMessage } from './types'; import { ThemeScopeProvider, useAIThemeScopeClassName } from '../themeScope'; +import type { + AIConversationController, + AIConversationControllerState, +} from './controller/AIConversationController'; +import { editActionOf, latestUserMessageIdOf, replyActionsOf } from './controller/offers'; +import { useAIConversationState } from './controller/useAIConversationState'; // How far from the end still counts as at the end, in pixels. const END_SLACK = 32; @@ -46,6 +52,8 @@ export type AIConversationUserMessageProps = { text: string; /** The message, or `undefined` for one on its way. */ message?: AIMessage; + /** Edits the message: a `conversation` offers it on the person's latest message. */ + onEdit?: () => void; }; export type AIConversationAssistantMessageProps = { @@ -80,8 +88,17 @@ export type AIConversationReplyActionsProps = { }; export type AIConversationProps = { - /** The conversation, oldest message first. */ - messages: AIMessage[]; + /** + * The conversation's state and what the person does in it, such as an + * `AIConversationController`. The conversation shows its messages, the message on its way and + * whether the agent thinks, loads its older messages, and offers rating and regenerating + * replies and editing the person's latest message. It takes the place of `messages`, + * `pendingMessage`, `isThinking` and `loadOlderMessages`, its reply actions are used unless + * `replyActions` is passed, and its `approver` unless one is passed. + */ + conversation?: AIConversationController; + /** The conversation, oldest message first. Required without `conversation`. */ + messages?: AIMessage[]; /** Who answers tool calls' questions and sign-ins on this device, to ask them. */ approver?: AIToolApprover; /** The view of the agent's replies. Defaults to `AIAssistantMessage`. */ @@ -134,10 +151,18 @@ export type AIConversationProps = { UserMessage?: ComponentType; }; -const DefaultUserMessage = ({ attachments, text }: AIConversationUserMessageProps) => ( - +const DefaultUserMessage = ({ + attachments, + onEdit, + text, +}: AIConversationUserMessageProps) => ( + ); +const NO_MESSAGES: AIMessage[] = []; + +type EditAction = (message: AIMessage) => (() => void) | undefined; + const DefaultAssistantMessage = ({ approver, attachments, @@ -158,6 +183,7 @@ const DefaultAssistantMessage = ({ type EntryViewProps = { AssistantMessage: ComponentType; + editAction?: EditAction; entry: AIConversationEntry; ReplyActions: ComponentType | null; UserMessage: ComponentType; @@ -170,6 +196,7 @@ type EntryViewProps = { const EntryView = ({ approver, AssistantMessage, + editAction, entry, ReplyActions, ui, @@ -190,6 +217,7 @@ const EntryView = ({ : (message.attachments ?? []) } message={message} + onEdit={editAction?.(message)} text={message.text} /> ); @@ -270,23 +298,96 @@ const scrollScroller = (scroller: HTMLElement, top: number, smooth: boolean) => * - A button scrolls back to the latest message when the end is out of view. * - A message the person sent shows at once (`pendingMessage`) and the agent thinks under it * (`AIThinking`) until its reply shows something. + * + * With a `conversation`, such as an `AIConversationController`, it shows the conversation's + * state and offers what the person can do in it. Without one, pass its `messages` and the rest. */ -export const AIConversation = ({ +export const AIConversation = (props: AIConversationProps) => + props.conversation ? ( + + ) : ( + + ); + +const controlledSelector = ({ + hasOlderMessages, + messages, + pendingMessage, + ratings, + status, +}: AIConversationControllerState) => ({ + hasOlderMessages, + messages, + pendingMessage, + ratings, + status, +}); + +// The conversation as its controller has it. The controller knows when its agent is thinking, +// including after a stop or a failure that leaves the person's message on its way. +const ControlledConversation = ({ + conversation, + ...props +}: AIConversationProps & { conversation: AIConversationController }) => { + const { hasOlderMessages, messages, pendingMessage, ratings, status } = + useAIConversationState(conversation, controlledSelector); + const replyActions = useMemo( + () => + (message: AIMessage, { isLatestReply }: { isLatestReply: boolean }) => + replyActionsOf(conversation, { ratings, status }, message, isLatestReply), + [conversation, ratings, status], + ); + const latestUserMessageId = latestUserMessageIdOf(messages); + const editAction = useMemo( + () => (message) => + editActionOf(conversation, { latestUserMessageId, status }, message), + [conversation, latestUserMessageId, status], + ); + const { ui } = props; + // A reply that shows only its UI has started, which the controller can't tell without the + // host that reads it. + const last = messages[messages.length - 1]; + const showsReply = + !!last && last.role !== 'user' && !!last.uiDocument && !!ui?.canShow(last.uiDocument); + return ( + + ); +}; + +type ConversationViewProps = Omit & { + editAction?: EditAction; + // Whether `isThinking` alone says whether the agent is thinking. + isThinkingKnown?: boolean; +}; + +const ConversationView = ({ approver, AssistantMessage = DefaultAssistantMessage, bottomInset = 0, className, + editAction, emptyContent, footer, isThinking = false, + isThinkingKnown = false, loadOlderMessages, - messages, + messages = NO_MESSAGES, pendingMessage, ReplyActions: ReplyActionsProp, replyActions, ui, UserMessage = DefaultUserMessage, -}: AIConversationProps) => { +}: ConversationViewProps) => { const themeScopeClassName = useAIThemeScopeClassName(); const { t } = useTranslationContext(); const { IconArrowDown, IconLoading } = useComponentContextIcons(); @@ -305,9 +406,13 @@ export const AIConversation = ({ [canShowUI, messages, pendingMessage, tracker], ); // after `entries`, which matched the copies - const thinking = - isThinking || tracker.isAwaitingReply(messages, pendingMessage, canShowUI); - const turns = useMemo(() => turnsOf(entries), [entries]); + const thinking = isThinkingKnown + ? isThinking + : isThinking || tracker.isAwaitingReply(messages, pendingMessage, canShowUI); + // The rows that started a turn keep starting one, so a turn keeps its key as older messages + // load above it. + const turnStarts = useRef(new Set()); + const turns = useMemo(() => turnsOf(entries, turnStarts.current), [entries]); const latest = turns[turns.length - 1]; const latestHasReplies = (latest?.entries.length ?? 0) > 1; const latestHasFirstStep = firstStepOf(latest) !== undefined; @@ -551,6 +656,7 @@ export const AIConversation = ({ = { + /** The conversation as it shows, oldest message first. */ + messages: readonly Message[]; + /** + * The message the person sent last that is not in the conversation yet. It stays until its + * reply starts, and after it was stopped or failed, while `retry()` can send it again. + */ + pendingMessage: AIPendingMessage | undefined; + /** Where the latest exchange with the agent is. */ + status: AIConversationStatus; + /** + * Why the latest request failed, or, as an `AIAgentError`, what the agent said when it couldn't + * answer or that it didn't answer in time. It stays until the next request, or until a reply + * starts after all. + */ + error: Error | undefined; + /** What the agent says it is doing besides what its reply shows, such as checking sources. */ + activity: AIAgentActivity | undefined; + /** Whether the conversation has messages before the first one, which `loadOlderMessages` loads. */ + hasOlderMessages: boolean; + /** + * How the person rated replies, by the reply's id: the rating they gave in the browser, or else + * the reply's own `rating`. + */ + ratings: Readonly>; +}; + +export type AIConversationControllerOptions = { + /** How the conversation reaches the agent and learns what it holds. Connected straight away. */ + transport: AIConversationTransport; + /** What the conversation's views offer. All of them by default. */ + actions?: AIConversationActions; + /** Who answers tool calls' questions and sign-ins in the browser, which the conversation asks them. */ + approver?: AIToolApprover; + /** What the person writes, such as a store the app fills itself. A new one by default. */ + composer?: StateStore; + /** + * How many milliseconds the controller waits, once the agent has the request, for the agent to + * start its reply or say what it is doing, before it shows an `AIAgentError`. While the agent + * says it is working, such as thinking, it waits as long as that takes. Five minutes by default; + * `null` or `Infinity` waits as long as it takes. + */ + replyTimeout?: number | null; +}; + +/** What the person sends: what `AIMessageComposer` hands on. */ +export type AIConversationSendData = { + text: string; + /** The files the person picked. */ + attachments?: File[]; + /** The option the person picked in the composer. */ + chatOption?: AIChatOption; + custom?: Record; +}; + +const DEFAULT_REPLY_TIMEOUT = 5 * 60 * 1000; +// The longest `setTimeout` waits; anything longer waits as long as it takes. +const MAX_TIMEOUT = 2 ** 31 - 1; + +const ALL_ACTIONS: Required = { + edit: true, + rate: true, + regenerate: true, +}; + +const isAbortError = (error: unknown) => + (error as { name?: unknown } | null)?.name === 'AbortError'; + +const asError = (error: unknown) => + error instanceof Error ? error : new Error(String(error)); + +const sameItems = (a: readonly T[], b: readonly T[]) => + a.length === b.length && a.every((item, index) => item === b[index]); + +const sameRatings = ( + a: Readonly>, + b: Readonly>, +) => { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); +}; + +// A rating the person gave a reply in the browser, shown in place of the reply's own. +type RatingChange = { + shown: AIMessageRating | null; + // What the transport has, which the rating goes back to when it can't take a new one. + sent: AIMessageRating | null; + // Tells the latest rating of the reply apart from those before it. + attempt: number; + // Sends the latest rating, after those before it. + sending?: Promise; +}; + +/** + * The state behind an AI conversation, and what the person does in it. + * + * It holds the conversation's messages, where the latest exchange with the agent is and why it + * failed, and it sends what the person asks through its `AIConversationTransport`. Give it to + * `AIConversation` and `AIMessageComposer` as `conversation`, which show it and act on it: + * sending, stopping, regenerating and rating replies, and editing the person's latest message. + * + * Create it in an effect and dispose of it in that effect's cleanup, so React's StrictMode, + * which runs effects twice, ends with a controller that is connected: + * + * ```tsx + * const [conversation, setConversation] = useState(); + * useEffect(() => { + * const controller = new AIConversationController({ transport }); + * setConversation(controller); + * return () => controller.dispose(); + * }, [transport]); + * + * if (!conversation) return null; + * + * … + * ``` + * + * What the person sends shows straight away, and the agent thinks until its reply starts. While + * a reply is written again, or a message the person edited is answered again, the messages it + * replaces are left out of `messages` until the new reply starts. + */ +export class AIConversationController { + /** The conversation's state, to follow with `useStateStore`. */ + readonly state: StateStore>; + /** What the person is writing: `AIMessageComposer` shows it, and editing a message fills it. */ + readonly composer: StateStore; + /** Who answers tool calls' questions and sign-ins in the browser. */ + readonly approver: AIToolApprover | undefined; + /** What the conversation's views offer the person to do with its messages. */ + readonly actions: Required; + + private readonly transport: AIConversationTransport; + private readonly replyTimeout: number | null; + private readonly disconnect: (() => void) | undefined; + private disposed = false; + // The messages as the transport last told them, including those left out of `messages`. + private received: readonly Message[] = []; + // The latest request, until its reply starts or another request takes its place. + private exchange: AIConversationExchange | undefined; + // Counts the requests and stops, so the outcome of one that was overtaken is ignored. + private generation = 0; + // Replies stopped while they were written, which are finished whatever they say. + private stopped = new Set(); + private activity: AIAgentActivity | undefined; + // Whether what the agent says it is doing is ignored, after it was stopped, until it stops + // saying anything. + private ignoresActivity = false; + private error: Error | undefined; + private hasOlderMessages = false; + // The latest stop, which the next request waits for: the agent would stop that one too. + private stopping: Promise | undefined; + // Ends the wait for the latest request's reply when the agent says nothing for too long. + private replyTimer: ReturnType | undefined; + private edited: Message | undefined; + private ratingChanges = new Map(); + private ratingAttempts = 0; + private isLoadingOlderMessages = false; + // The object URLs the files the person picked show from while their message is on its way. + private fileURLs = new Map(); + + constructor({ + actions, + approver, + composer, + replyTimeout = DEFAULT_REPLY_TIMEOUT, + transport, + }: AIConversationControllerOptions) { + this.transport = transport; + this.composer = composer ?? createAIMessageComposerStore(); + this.approver = approver; + this.actions = { ...ALL_ACTIONS, ...actions }; + this.replyTimeout = replyTimeout; + this.state = new StateStore>({ + activity: undefined, + error: undefined, + hasOlderMessages: false, + messages: [], + pendingMessage: undefined, + ratings: {}, + status: 'ready', + }); + const disconnect = transport.connect(this.makeFeed()); + this.disconnect = typeof disconnect === 'function' ? disconnect : undefined; + } + + /** + * The person's message they are editing in the composer, which the next message they send + * replaces. The composer shows that they are editing it, with a button that cancels it. + */ + get editingMessage() { + return this.composer.getLatestValue().isEditing ? this.edited : undefined; + } + + /** Stops following the transport and lets go of what is on its way. */ + dispose() { + if (this.disposed) return; + this.disposed = true; + this.clearReplyTimer(); + this.disconnect?.(); + if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') { + this.fileURLs.forEach((url) => URL.revokeObjectURL(url)); + } + this.fileURLs.clear(); + } + + // What the person does + + /** Sends a message, or the person's edit of the message they are editing. */ + send({ attachments = [], chatOption, custom, text }: AIConversationSendData) { + if (text.trim() === '' && attachments.length === 0) return; + const editing = this.editingMessage; + this.edited = undefined; + if (this.composer.getLatestValue().isEditing) + this.composer.partialNext({ isEditing: false }); + this.start({ + attachments, + chatOption, + custom, + kind: editing ? 'edit' : 'message', + message: editing, + messageId: generateId(), + text, + }); + } + + /** + * Stops the reply the agent is writing. What it wrote so far stays. When the reply hasn't + * started yet, the person's message stays, and a reply that still arrives is finished whatever + * it says. + */ + stop() { + if (!isAIConversationAnswering(this.state.getLatestValue().status)) return; + const id = ++this.generation; + this.activity = undefined; + this.ignoresActivity = true; + if (this.exchange?.state === 'waiting') this.exchange.state = 'stopped'; + const writing = this.writingReply; + if (writing) this.stopped.add(writing); + this.refresh(); + this.stopping = (async () => { + try { + await this.transport.stop(); + } catch (error) { + this.stopFailed(id, writing, error); + } + })(); + } + + /** Asks the agent for a new reply in place of `reply`, and of those after it. */ + regenerate(reply: Message) { + const index = this.indexOf(reply); + if (index < 0 || this.received[index].role === 'user') return; + this.start(this.regenerateRequest(index)); + } + + /** Puts a message of the person's in the composer to edit. The next message they send replaces it. */ + edit(message: Message) { + if (message.role !== 'user') return; + this.edited = message; + this.composer.partialNext({ isEditing: true, text: message.text }); + } + + /** Stops editing a message, and empties the composer. */ + cancelEditing() { + if (!this.editingMessage) return; + this.edited = undefined; + this.composer.partialNext({ isEditing: false, text: '' }); + } + + /** + * Tries again after `error`: sends the request that failed again, with the same message id, or + * asks the agent again for the reply it couldn't write. + */ + retry() { + const { exchange } = this; + if (!exchange?.hasFailed) return; + this.start(exchange.retryRequest(this.received)); + } + + /** Forgets `error`, and the request that failed. */ + clearError() { + this.error = undefined; + if (this.exchange?.hasFailed) this.exchange = undefined; + this.refresh(); + } + + /** + * Rates a reply, or takes the rating back with `null`. Ratings reach the transport in the order + * the person gives them, and the reply's rating goes back to the one the transport has if it + * can't take the new one. + */ + rate(reply: Message, rating: AIMessageRating | null) { + const current = this.state.getLatestValue().ratings[reply.id] ?? null; + if (current === rating) return; + const attempt = ++this.ratingAttempts; + const change = this.ratingChanges.get(reply.id) ?? { + attempt: 0, + sent: current, + shown: current, + }; + const earlier = change.sending; + change.shown = rating; + change.attempt = attempt; + change.sending = (async () => { + await earlier; + await this.sendRating(rating, reply, attempt); + })(); + this.ratingChanges.set(reply.id, change); + this.refresh(); + } + + /** + * Loads the messages before the first one, while there are any. Rejects when they couldn't load, + * so the conversation waits before it asks again. + */ + loadOlderMessages = async () => { + if (!this.hasOlderMessages || this.isLoadingOlderMessages) return; + this.isLoadingOlderMessages = true; + try { + await this.transport.loadOlderMessages?.(); + } finally { + this.isLoadingOlderMessages = false; + } + }; + + // What the conversation's views offer + + /** + * What the person can do with a finished reply: rate it, and write it again while it is the + * latest and the agent isn't answering. + */ + replyActions( + reply: Message, + isLatestReply: boolean, + ): Omit { + return replyActionsOf(this, this.state.getLatestValue(), reply, isLatestReply); + } + + /** Edits a message of the person's, when it is their latest and the agent isn't answering. */ + editAction(message: Message): (() => void) | undefined { + const { messages, status } = this.state.getLatestValue(); + return editActionOf( + this, + { latestUserMessageId: latestUserMessageIdOf(messages), status }, + message, + ); + } + + // What the transport says + + private makeFeed(): AIConversationFeed { + const isDisposed = () => this.disposed; + return { + get isConnected() { + return !isDisposed(); + }, + updateActivity: (activity) => { + if (!this.disposed) this.receiveActivity(activity); + }, + updateHasOlderMessages: (hasOlderMessages) => { + if (this.disposed || this.hasOlderMessages === hasOlderMessages) return; + this.hasOlderMessages = hasOlderMessages; + this.refresh(); + }, + updateMessages: (messages) => { + if (!this.disposed) this.receiveMessages(messages); + }, + }; + } + + private receiveMessages(messages: readonly Message[]) { + this.received = messages; + const { exchange } = this; + const reply = exchange?.reply(messages); + if (exchange && reply) this.receiveReply(reply, exchange); + this.refresh(); + } + + private receiveActivity(activity: AIAgentActivity | undefined) { + if (!activity) { + this.ignoresActivity = false; + this.activity = undefined; + // The agent stopped saying it is working without starting its reply. + if (this.exchange?.state === 'waiting') this.waitForReply(this.exchange.id); + this.refresh(); + return; + } + if (this.ignoresActivity) return; + this.activity = activity; + if (activity.state === AIAgentActivityStates.error) { + this.agentCouldNotAnswer(new AIAgentError(activity.text)); + } else { + // The agent is working on it, however long it thinks. + this.clearReplyTimer(); + } + this.refresh(); + } + + // Private + + private start(request: AIConversationRequest) { + const id = ++this.generation; + this.error = undefined; + this.activity = undefined; + this.ignoresActivity = false; + this.stopped = new Set(); + this.clearReplyTimer(); + this.exchange = new AIConversationExchange(id, request, this.received, this.urlOf); + this.refresh(); + const previousStop = this.stopping; + void (async () => { + // The agent would stop a request that reaches it before the stop made before it. + await previousStop; + if (this.disposed) return; + try { + await this.transport.send(request); + this.requestSent(id); + } catch (error) { + this.requestFailed(id, error); + } + })(); + } + + // The reply to the latest request arrived: once it shows something, or as soon as it arrives + // after the person stopped the request. + private receiveReply(reply: Message, exchange: AIConversationExchange) { + if (exchange.state === 'stopped') { + if (reply.isGenerating) this.stopped.add(reply.id); + this.exchange = undefined; + } else if (!hasEmptyContent(reply) || !reply.isGenerating) { + this.exchange = undefined; + this.clearReplyTimer(); + if (exchange.hasFailed) { + // The agent answered after all. + this.error = undefined; + this.stopped.delete(reply.id); + } + } + } + + private requestSent(id: number) { + const { exchange } = this; + // The agent may already say it is working on it. + if ( + this.disposed || + exchange?.id !== id || + exchange.state !== 'waiting' || + this.activity + ) { + return; + } + this.waitForReply(id); + } + + private requestFailed(id: number, error: unknown) { + const { exchange } = this; + // Its reply started, so the agent had it, another request took its place, or it already + // failed. + if (this.disposed || exchange?.id !== id || exchange.hasFailed) return; + if (isAbortError(error)) { + this.exchange = undefined; + } else { + exchange.state = 'failed'; + this.error = asError(error); + } + this.refresh(); + } + + // The agent goes on answering, and the composer offers to stop it again. + private stopFailed(id: number, writing: string | undefined, error: unknown) { + if (this.disposed || id !== this.generation || isAbortError(error)) return; + if (writing) this.stopped.delete(writing); + const { exchange } = this; + if (exchange?.state === 'stopped') { + exchange.state = 'waiting'; + this.waitForReply(exchange.id); + } + this.ignoresActivity = false; + this.refresh(); + } + + // The agent couldn't answer the latest request, or the latest message: what it was writing is + // finished, and `retry()` asks it again. + private agentCouldNotAnswer(error: AIAgentError) { + const writing = this.writingReply; + if (writing) this.stopped.add(writing); + const request = this.exchange ? undefined : this.answerAgainRequest; + if (request) { + this.exchange = new AIConversationExchange( + ++this.generation, + request, + this.received, + this.urlOf, + ); + } + if (this.exchange) this.exchange.state = 'unanswered'; + this.clearReplyTimer(); + this.error = error; + } + + private waitForReply(id: number) { + this.clearReplyTimer(); + const timeout = this.replyTimeout; + if (timeout === null || !Number.isFinite(timeout) || timeout > MAX_TIMEOUT) return; + this.replyTimer = setTimeout(() => this.replyTimedOut(id), Math.max(timeout, 0)); + } + + private clearReplyTimer() { + if (this.replyTimer !== undefined) clearTimeout(this.replyTimer); + this.replyTimer = undefined; + } + + private replyTimedOut(id: number) { + this.replyTimer = undefined; + const { exchange } = this; + if (this.disposed || exchange?.id !== id || exchange.state !== 'waiting') return; + this.agentCouldNotAnswer(new AIAgentError()); + this.refresh(); + } + + // Sends the latest rating of a reply. Those it replaced while one was on its way aren't. + private async sendRating( + rating: AIMessageRating | null, + reply: Message, + attempt: number, + ) { + if (this.disposed || this.ratingChanges.get(reply.id)?.attempt !== attempt) return; + try { + await this.transport.rate?.(reply, rating); + const change = this.ratingChanges.get(reply.id); + if (change) change.sent = rating; + } catch { + const change = this.ratingChanges.get(reply.id); + if (this.disposed || !change || change.attempt !== attempt) return; + change.shown = change.sent; + this.refresh(); + } + } + + // Asks for a new reply in place of the one at `index`, to the person's message before it. + private regenerateRequest(index: number): AIConversationRequest { + const prompt = this.received + .slice(0, index) + .reverse() + .find((message) => message.role === 'user'); + return { + attachments: [], + kind: 'regenerate', + message: this.received[index], + messageId: generateId(), + text: prompt?.text ?? '', + }; + } + + // Asks again for the latest message's reply: the reply written again, or one to the person's + // message, which has none. + private get answerAgainRequest(): AIConversationRequest | undefined { + const last = this.received.length - 1; + if (last < 0) return undefined; + if (this.received[last].role !== 'user') return this.regenerateRequest(last); + return { + attachments: [], + kind: 'regenerate', + messageId: generateId(), + text: this.received[last].text, + }; + } + + // Where a file the person picked shows from while their message is on its way: one object URL + // per file, made once, and let go of once the controller is disposed. + private urlOf = (file: File) => { + const known = this.fileURLs.get(file); + if (known !== undefined || this.disposed) return known; + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + return undefined; + } + const url = URL.createObjectURL(file); + this.fileURLs.set(file, url); + return url; + }; + + // The latest reply, while the agent writes it. + private get writingReply() { + const last = this.received[this.received.length - 1]; + return last && last.role !== 'user' && last.isGenerating ? last.id : undefined; + } + + private indexOf(message: Message) { + return this.received.findIndex((item) => item.id === message.id); + } + + // Works out what the conversation shows and where it is, after anything changes. + private refresh() { + const current = this.state.getLatestValue(); + const replaced = + this.exchange?.state === 'waiting' ? this.exchange.replaced : undefined; + let messages: readonly Message[] = + !replaced && this.stopped.size === 0 + ? this.received + : this.received.filter( + (message) => + // A reply stopped before it showed anything has nothing to show, and the + // conversation would think it is still on its way. + !replaced?.has(message.id) && + !(this.stopped.has(message.id) && hasEmptyContent(message)), + ); + if (sameItems(messages, current.messages)) messages = current.messages; + const shownPending = this.exchange?.shownPending; + const pendingMessage = + current.pendingMessage?.id === shownPending?.id + ? current.pendingMessage + : shownPending; + const nextRatings = this.currentRatings(); + const ratings = sameRatings(nextRatings, current.ratings) + ? current.ratings + : nextRatings; + const next: AIConversationControllerState = { + activity: this.activity, + error: this.error, + hasOlderMessages: this.hasOlderMessages, + messages, + pendingMessage, + ratings, + status: this.currentStatus(messages), + }; + const changed = (Object.keys(next) as (keyof typeof next)[]).some( + (key) => next[key] !== current[key], + ); + if (changed) this.state.next(next); + } + + private currentRatings() { + const ratings: Record = {}; + for (const message of this.received) { + if (message.role !== 'user' && message.rating) ratings[message.id] = message.rating; + } + for (const [id, change] of this.ratingChanges) { + if (change.shown) ratings[id] = change.shown; + else delete ratings[id]; + } + return ratings; + } + + private currentStatus(messages: readonly Message[]): AIConversationStatus { + if (this.exchange?.state === 'waiting') return 'submitted'; + const last = messages[messages.length - 1]; + if (last && last.role !== 'user' && last.isGenerating && !this.stopped.has(last.id)) { + return hasEmptyContent(last) ? 'submitted' : 'streaming'; + } + // The agent is working on the person's latest message, whose reply isn't here yet. + if ( + this.activity && + this.activity.state !== AIAgentActivityStates.error && + (!last || last.role === 'user') + ) { + return 'submitted'; + } + return this.error ? 'error' : 'ready'; + } +} diff --git a/src/plugins/AIComponents/Conversation/controller/AIConversationExchange.ts b/src/plugins/AIComponents/Conversation/controller/AIConversationExchange.ts new file mode 100644 index 000000000..31db03bd8 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/controller/AIConversationExchange.ts @@ -0,0 +1,149 @@ +import isEqual from 'react-fast-compare'; + +import type { AIMessageAttachmentData } from '../../Message/AIMessageAttachment'; +import type { AIMessage, AIPendingMessage } from '../types'; +import type { AIConversationRequest } from './types'; + +// Where the request is: waiting for its reply, stopped by the person before it started, not +// sent because the transport failed, or not answered by the agent. +export type AIConversationExchangeState = 'waiting' | 'stopped' | 'failed' | 'unanswered'; + +/** Where a file the person picked shows from while it is on its way, such as an object URL. */ +export type AIFileURLOf = (file: File) => string | undefined; + +// A reply the agent has started with no text, steps or attachments yet. Its generated UI can +// still show, when the conversation's host reads it. +export const hasEmptyContent = (message: AIMessage) => + message.role !== 'user' && + message.text === '' && + !message.parts?.length && + !message.attachments?.length; + +// Whether a reply is written again since it was `original`: it started anew, or it shows +// something else. +const isRewritten = (message: AIMessage, original: AIMessage) => + (!!message.isGenerating && !original.isGenerating) || + message.text !== original.text || + !isEqual(message.parts ?? [], original.parts ?? []) || + !isEqual(message.attachments ?? [], original.attachments ?? []) || + !isEqual(message.uiDocument, original.uiDocument); + +export const attachmentOfFile = ( + file: File, + urlOf?: AIFileURLOf, +): AIMessageAttachmentData => ({ + kind: !file.type || file.type.startsWith('image') ? 'image' : 'file', + mimeType: file.type?.includes('/') ? file.type : undefined, + name: file.name, + size: file.size, + url: urlOf?.(file), +}); + +/** A unique id, such as for the person's message. */ +export const generateId = () => + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; + +// The latest request, until its reply starts or another request takes its place. +export class AIConversationExchange { + state: AIConversationExchangeState = 'waiting'; + // The messages there were when it was made, none of which is its reply. + readonly known: Set; + // The messages it replaces, as they were, which the conversation leaves out while it waits: + // the reply written again, or the message edited, and those after it. + readonly replaced: Map; + // The person's message on its way. + readonly pending: AIPendingMessage | undefined; + + constructor( + // Tells it apart from the requests before and after it. + readonly id: number, + readonly request: AIConversationRequest, + conversation: readonly Message[], + // Where the files the person picked show from while their message is on its way. + urlOf?: AIFileURLOf, + ) { + this.known = new Set(conversation.map((message) => message.id)); + const target = request.message + ? conversation.findIndex((message) => message.id === request.message?.id) + : -1; + this.replaced = new Map( + target >= 0 && (request.kind === 'regenerate' || request.kind === 'edit') + ? conversation.slice(target).map((message) => [message.id, message]) + : [], + ); + if (request.kind === 'regenerate') { + this.pending = undefined; + } else { + const edited = request.kind === 'edit' ? (request.message?.attachments ?? []) : []; + this.pending = { + attachments: [ + ...edited, + ...request.attachments.map((file) => attachmentOfFile(file, urlOf)), + ], + id: request.messageId, + text: request.text, + }; + } + } + + get hasFailed() { + return this.state === 'failed' || this.state === 'unanswered'; + } + + // The person's message while the conversation shows it: until its reply starts, and for a + // new message also once it is stopped or failed, since the agent may have it, or `retry()` + // sends it again. The messages an edit replaces come back instead. + get shownPending() { + return this.state === 'waiting' || this.request.kind === 'message' + ? this.pending + : undefined; + } + + // The latest message when it is the request's reply: a reply that wasn't there, or one the + // request replaces, once it is written again. + reply(conversation: readonly Message[]) { + const last = conversation[conversation.length - 1]; + if (!last || last.role === 'user') return undefined; + const original = this.replaced.get(last.id); + const rewritten = original ? isRewritten(last, original) : false; + return !this.known.has(last.id) || rewritten ? last : undefined; + } + + // Whether its reply has started: it shows something, or it is already finished. + hasReplyStarted(conversation: readonly Message[]) { + const reply = this.reply(conversation); + return !!reply && (!hasEmptyContent(reply) || !reply.isGenerating); + } + + // Whether the person's message is in the conversation: one with its id, or one of theirs + // after the messages there were. + hasMessageArrived(conversation: readonly Message[]) { + if (conversation.some((message) => message.id === this.request.messageId)) + return true; + let lastKnown = -1; + conversation.forEach((message, index) => { + if (this.known.has(message.id)) lastKnown = index; + }); + return conversation.slice(lastKnown + 1).some((message) => message.role === 'user'); + } + + // What `retry()` sends: the request again, or, when the agent had the person's message but + // couldn't answer it, a request for its reply, since sending it again would add it twice. + retryRequest(conversation: readonly Message[]): AIConversationRequest { + if ( + this.state !== 'unanswered' || + this.request.kind !== 'message' || + !this.hasMessageArrived(conversation) + ) { + return this.request; + } + return { + attachments: [], + kind: 'regenerate', + messageId: generateId(), + text: this.request.text, + }; + } +} diff --git a/src/plugins/AIComponents/Conversation/controller/index.ts b/src/plugins/AIComponents/Conversation/controller/index.ts new file mode 100644 index 000000000..fc1bac578 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/controller/index.ts @@ -0,0 +1,4 @@ +export * from './AIAgentError'; +export * from './AIConversationController'; +export * from './types'; +export * from './useAIConversationState'; diff --git a/src/plugins/AIComponents/Conversation/controller/offers.ts b/src/plugins/AIComponents/Conversation/controller/offers.ts new file mode 100644 index 000000000..9e0c499da --- /dev/null +++ b/src/plugins/AIComponents/Conversation/controller/offers.ts @@ -0,0 +1,60 @@ +import { type AIConversationStatus, isAIConversationAnswering } from './types'; + +import type { AIMessageRating, AIReplyActionsProps } from '../../Message/AIReplyActions'; +import type { AIMessage } from '../types'; + +type Offerer = { + actions: { edit: boolean; rate: boolean; regenerate: boolean }; + edit: (message: Message) => void; + rate: (reply: Message, rating: AIMessageRating | null) => void; + regenerate: (reply: Message) => void; +}; + +// What the person can do with a finished reply: rate it, and write it again while it is the +// latest and the agent isn't answering. +export const replyActionsOf = ( + offerer: Offerer, + { + ratings, + status, + }: { + ratings: Readonly>; + status: AIConversationStatus; + }, + reply: Message, + isLatestReply: boolean, +): Omit => ({ + onRate: offerer.actions.rate ? (rating) => offerer.rate(reply, rating) : undefined, + onRegenerate: + offerer.actions.regenerate && isLatestReply && !isAIConversationAnswering(status) + ? () => offerer.regenerate(reply) + : undefined, + rating: ratings[reply.id] ?? null, +}); + +/** The id of the person's latest message, the one they can edit. */ +export const latestUserMessageIdOf = (messages: readonly AIMessage[]) => { + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index].role === 'user') return messages[index].id; + } + return undefined; +}; + +// Edits a message of the person's, when it is their latest and the agent isn't answering. +export const editActionOf = ( + offerer: Offerer, + { + latestUserMessageId, + status, + }: { latestUserMessageId: string | undefined; status: AIConversationStatus }, + message: Message, +): (() => void) | undefined => { + if ( + !offerer.actions.edit || + message.role !== 'user' || + isAIConversationAnswering(status) + ) { + return undefined; + } + return latestUserMessageId === message.id ? () => offerer.edit(message) : undefined; +}; diff --git a/src/plugins/AIComponents/Conversation/controller/types.ts b/src/plugins/AIComponents/Conversation/controller/types.ts new file mode 100644 index 000000000..967947269 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/controller/types.ts @@ -0,0 +1,156 @@ +import type { AIChatOption } from '../../AIMessageComposer/ChatOptions'; +import type { AIMessageRating } from '../../Message/AIReplyActions'; +import type { AIMessage } from '../types'; + +/** + * Where the latest exchange with the agent is: + * - `ready`: nothing is on its way: the agent answered, or the person stopped it. + * - `submitted`: the person's request is on its way, or the agent is working on it and its reply + * shows nothing yet. + * - `streaming`: the agent is writing its reply. + * - `error`: the latest request failed, or the agent couldn't answer it. `retry()` tries again. + */ +export type AIConversationStatus = 'ready' | 'submitted' | 'streaming' | 'error'; + +/** Whether the agent is answering: the request is submitted, or its reply streaming. */ +export const isAIConversationAnswering = (status: AIConversationStatus) => + status === 'submitted' || status === 'streaming'; + +/** + * What the person asks for: + * - `message`: a new message from the person. + * - `regenerate`: a new reply in place of the request's `message`, which is a reply, and of those + * after it. Without `message`, a reply to the latest message, which has none, such as after the + * agent couldn't answer it. + * - `edit`: the person changed the request's `message`, one of theirs, to say its `text`: it is + * answered again, in place of the replies after it. + * + * An open set: a kind of your own is on its way like a message. + */ +export type AIConversationRequestKind = 'message' | 'regenerate' | 'edit' | (string & {}); + +/** What the person asks the agent, as an `AIConversationTransport` sends it. */ +export type AIConversationRequest = { + /** What the person asks for. */ + kind: AIConversationRequestKind; + /** + * An id for the person's message. A transport that creates the message with it lets the + * conversation find its copy by it. A retry of the request keeps it. + */ + messageId: string; + /** What the person wrote. When a reply is written again, the message it answers says it. */ + text: string; + /** The files the person picked, in the browser, to upload with their message. */ + attachments: File[]; + /** The option the person picked in the composer, such as thinking harder. */ + chatOption?: AIChatOption; + /** + * What the composer held besides, as `AIMessageComposerStore.custom`, or what the app sent + * with the request, such as a generated UI's action as `custom.uiAction`. + */ + custom?: Record; + /** The message it is about: the reply to write again, or the person's message they edited. */ + message?: Message; +}; + +/** The ids of the states Stream Chat's AI indicator reports. */ +export const AIAgentActivityStates = { + checkingExternalSources: 'AI_STATE_EXTERNAL_SOURCES', + error: 'AI_STATE_ERROR', + generating: 'AI_STATE_GENERATING', + thinking: 'AI_STATE_THINKING', +} as const; + +/** + * What the agent is doing. An open set: compare against the states you know. Those of + * `AIAgentActivityStates` are Stream Chat's AI indicator's. + */ +export type AIAgentActivityState = + | (typeof AIAgentActivityStates)[keyof typeof AIAgentActivityStates] + | (string & {}); + +/** What the agent says it is doing besides what its reply shows, such as checking sources. */ +export type AIAgentActivity = { + state: AIAgentActivityState; + /** What it says about it, such as "Searching the web", or why it couldn't answer. */ + text?: string; +}; + +/** What the conversation's views offer the person to do with its messages. All by default. */ +export type AIConversationActions = { + /** Editing the person's latest message, which is then answered again. */ + edit?: boolean; + /** Rating replies. */ + rate?: boolean; + /** Asking for a new reply in place of the latest. */ + regenerate?: boolean; +}; + +/** + * Where an `AIConversationTransport` tells its `AIConversationController` what the conversation + * holds. Once the controller is disposed, its updates go nowhere. + */ +export type AIConversationFeed = { + /** Whether the controller it updates is still there. Stop following what it holds once it isn't. */ + readonly isConnected: boolean; + /** The conversation's messages, oldest first, each time they change. */ + updateMessages: (messages: readonly Message[]) => void; + /** Whether there are messages before the first one, which `loadOlderMessages` loads. */ + updateHasOlderMessages: (hasOlderMessages: boolean) => void; + /** What the agent says it is doing, or `undefined` once it stops saying. */ + updateActivity: (activity: AIAgentActivity | undefined) => void; +}; + +/** + * How an `AIConversationController` reaches the agent and learns what it holds: it sends what the + * person asks, stops the agent, and tells the controller its messages as they change. + * + * With Stream Chat, the conversation is a channel: the transport follows its messages and AI + * indicator events, and sends each request through the channel or your agent's own API. + * + * ```ts + * const transport: AIConversationTransport = { + * connect: (feed) => { + * const update = () => feed.updateMessages(messagesOf(channel).map(fromStreamMessage)); + * update(); + * return channel.on(update).unsubscribe; + * }, + * send: (request) => agent.respond(request.text, request.attachments), + * stop: () => agent.interrupt(), + * }; + * ``` + */ +export type AIConversationTransport = { + /** + * Starts telling `feed` what the conversation holds: its messages each time they change, + * whether there are older ones to load, and what the agent says it is doing. The controller + * calls it once, as it is made, and calls what it returns once it is disposed. + */ + connect: (feed: AIConversationFeed) => void | (() => void); + /** + * Sends what the person asks. Resolve once the agent has the request: its reply arrives + * through the feed. A rejection shows as the conversation's error, and the person can retry, + * which sends the same request again with the same `messageId`: if the first attempt reached + * your backend, or you keep a copy of the failed message, use the id so the message isn't added + * twice. A rejection with an `AbortError` ends the request without an error. + * + * The controller leaves out the messages a `regenerate` or an `edit` replaces only until the + * new reply starts. From then on it shows the messages as the feed has them, so update the + * conversation before the new reply arrives: + * - For a regenerate, remove the request's `message` and the messages after it, or write the + * new reply in place of `message`, under its id. + * - For an edit, change the person's `message` to say the request's `text`, under its id, and + * remove the messages after it. + */ + send: (request: AIConversationRequest) => Promise; + /** Stops the reply the agent is writing. What it wrote so far stays. */ + stop: () => Promise; + /** Loads the messages before the first one, which arrive through the feed. */ + loadOlderMessages?: () => Promise; + /** + * Tells your backend how the person rated a reply, or that they took their rating back with + * `null`. Without it, the controller keeps the ratings in the browser. Ratings your backend has + * show through each message's `rating`. + */ + rate?: (reply: Message, rating: AIMessageRating | null) => Promise; +}; diff --git a/src/plugins/AIComponents/Conversation/controller/useAIConversationState.ts b/src/plugins/AIComponents/Conversation/controller/useAIConversationState.ts new file mode 100644 index 000000000..9838dcdb0 --- /dev/null +++ b/src/plugins/AIComponents/Conversation/controller/useAIConversationState.ts @@ -0,0 +1,19 @@ +import { useStateStore } from '../../../../store'; +import type { AIMessage } from '../types'; +import type { + AIConversationController, + AIConversationControllerState, +} from './AIConversationController'; + +/** + * Follows what `selector` picks of a conversation's state, such as its `status` or `error`: the + * component renders again when it changes. Keep `selector` outside the component, so it stays the + * same between renders. + */ +export const useAIConversationState = < + Message extends AIMessage, + Selected extends Readonly>, +>( + conversation: AIConversationController, + selector: (state: AIConversationControllerState) => Selected, +) => useStateStore(conversation.state, selector); diff --git a/src/plugins/AIComponents/Conversation/entries.ts b/src/plugins/AIComponents/Conversation/entries.ts index bd1bf42ad..631b9328e 100644 --- a/src/plugins/AIComponents/Conversation/entries.ts +++ b/src/plugins/AIComponents/Conversation/entries.ts @@ -25,6 +25,15 @@ export type AIConversationCanShowUI = (document: AIUIDocument) => boolean; const showsNoUI: AIConversationCanShowUI = () => false; +// The id of the person's newest message, looked for from the end: the copy of one they just sent +// is always it, and while a reply is written it is a step or two from the end. +const newestFromUserOf = (messages: AIMessage[]) => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === 'user') return messages[index].id; + } + return undefined; +}; + // A reply the agent has started but that has nothing to show yet: no text, steps or attachments, // and no UI that `canShowUI` says shows. const showsNothing = (message: AIMessage, canShowUI: AIConversationCanShowUI) => @@ -34,12 +43,18 @@ const showsNothing = (message: AIMessage, canShowUI: AIConversationCanShowUI) => !message.attachments?.length && !(message.uiDocument && canShowUI(message.uiDocument)); -/** Groups the rows into turns, each starting with one of the person's messages. */ -export const turnsOf = (entries: AIConversationEntry[]) => +/** + * Groups the rows into turns, each starting with one of the person's messages. A row that + * started a turn, kept in `starts`, keeps starting one: a reply that opened the first page loaded + * stays the start of its turn when older messages load above it, so the turn keeps its key. + */ +export const turnsOf = (entries: AIConversationEntry[], starts = new Set()) => entries.reduce((turns, entry) => { const last = turns[turns.length - 1]; - if (!last || entry.isFromUser) turns.push({ entries: [entry], id: entry.id }); - else last.entries.push(entry); + if (!last || entry.isFromUser || starts.has(entry.id)) { + starts.add(entry.id); + turns.push({ entries: [entry], id: entry.id }); + } else last.entries.push(entry); return turns; }, []); @@ -57,6 +72,10 @@ export class AIPendingMessageTracker { // The pending message of the last update, whose copy can arrive in the update that stops // passing it. private lastPending?: AIPendingMessage; + // The person's newest message as of the last update, `null` before the first one. A message on + // its way first seen in an update that also brings its copy finds it as the person's newest + // message, when that one is new. + private lastNewestFromUser: string | null | undefined = null; // The rows of a conversation: its messages, apart from replies with nothing to show yet, and // a message on its way until its copy arrives. A reply's UI shows when `canShowUI` says. @@ -66,13 +85,21 @@ export class AIPendingMessageTracker { canShowUI: AIConversationCanShowUI = showsNoUI, ): AIConversationEntry[] { let showsPending = !!pending; + const newestFromUser = newestFromUserOf(messages); // A copy that arrives in the update that stops passing its pending message still takes over // its row. Only then: a message sent later is not the copy of one that failed. const awaited = pending ?? this.lastPending; if (awaited) { const sent = messages.filter((message) => message.role === 'user'); const ids = new Set(sent.map((message) => message.id)); - const known = this.seen.get(awaited.id) ?? ids; + let known = this.seen.get(awaited.id); + if (!known) { + known = new Set(ids); + // The person's message that arrived with this update can be the copy. + const arrived = + this.lastNewestFromUser !== null && newestFromUser !== this.lastNewestFromUser; + if (arrived && newestFromUser !== undefined) known.delete(newestFromUser); + } const matched = [...this.copies].find( ([copyId, original]) => original.id === awaited.id && ids.has(copyId), )?.[0]; @@ -85,6 +112,7 @@ export class AIPendingMessageTracker { } } this.lastPending = pending ?? undefined; + this.lastNewestFromUser = newestFromUser; const entries: AIConversationEntry[] = messages .filter((message) => !showsNothing(message, canShowUI)) diff --git a/src/plugins/AIComponents/Conversation/index.ts b/src/plugins/AIComponents/Conversation/index.ts index 2ed522d1a..1ab87dc42 100644 --- a/src/plugins/AIComponents/Conversation/index.ts +++ b/src/plugins/AIComponents/Conversation/index.ts @@ -1,4 +1,5 @@ export * from './AIConversation'; +export * from './controller'; export * from './fromStreamMessage'; export * from './types'; export type { diff --git a/src/plugins/AIComponents/Conversation/types.ts b/src/plugins/AIComponents/Conversation/types.ts index 5facba8eb..d80ecf1fb 100644 --- a/src/plugins/AIComponents/Conversation/types.ts +++ b/src/plugins/AIComponents/Conversation/types.ts @@ -1,5 +1,6 @@ import type { AIUIDocument } from '../GenerativeUI/AIUIDocument'; import type { AIMessageAttachmentData } from '../Message/AIMessageAttachment'; +import type { AIMessageRating } from '../Message/AIReplyActions'; import type { AIMessagePartData } from '../MessageParts/types'; /** Who wrote a message: the person using the app (`user`) or the agent (`assistant`). */ @@ -19,6 +20,11 @@ export type AIMessage = { attachments?: AIMessageAttachmentData[]; /** Whether the agent is still writing it. */ isGenerating?: boolean; + /** + * How the person rated a reply, as your backend has it. An `AIConversationController` shows it + * until the person rates the reply in the browser. + */ + rating?: AIMessageRating | null; /** The steps the agent took for it, in order: reasoning, tool calls and kinds of its own. */ parts?: AIMessagePartData[]; /** diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index 63d8d19a4..ae8a10424 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -33,6 +33,7 @@ From `stream-chat-react/ai-components`: - Components: `AIMarkdown`, `AIMessageComposer` (with the static sub-components `FileInput`, `TextInput`, `SpeechToTextButton`, `SubmitButton`, `StopButton`, `Actions`, `ModelSelect`, `ChatOptions`, `AttachmentPreview`, `AttachmentPreview.Item`), `StreamingMessage`, `AIStateIndicator`, `VoiceWave`, `VoiceGlow` - Agent conversations (see [Agent conversations](#agent-conversations)): `AIConversation`, `AIUserMessage`, `AIAssistantMessage`, `AIMessageAttachment`, `AIReplyActions`, `AITypingIndicator`, `AISuggestions`, `AIMessageParts`, `AIMessagePart`, `AIToolCall`, `AIToolApproval`, `AIToolApprovalCard`, `AIToolArguments`, `AIToolAuthorization`, `AIToolAuthorizationCard`, `AIReasoning`, `AIThinking`, `AIToolApproverProvider`, `ChatOptionsList`, `SelectedChatOptionChip` +- Conversation state (see [`AIConversationController`](#aiconversationcontroller)): `AIConversationController`, `AIAgentError`, `AI_AGENT_ERROR_MESSAGE`, `AIAgentActivityStates`, `isAIConversationAnswering`, `useAIConversationState`, `createAIMessageComposerStore`, and the types `AIConversationControllerState`, `AIConversationControllerOptions`, `AIConversationSendData`, `AIConversationTransport`, `AIConversationFeed`, `AIConversationRequest`, `AIConversationRequestKind`, `AIConversationStatus`, `AIConversationActions`, `AIAgentActivity`, `AIAgentActivityState` - Functions: `parseMessageParts`, `parseMessagePart`, `isAwaitingApproval`, `isAwaitingAuthorization`, `isAwaitingClient`, `isDeclined`, `isToolCallFinished`, `isSupportedPart`, `isAuthorizationExpired`, `fromStreamMessage`, `createPopupAuthorizer`, `completeConnectorSignIn`, `readConnectorSignInResult`, `AIClientIdentity.installId()` - Hooks: `useAttachments`, `useText`, `useIsDisabled`, `useAIMessageComposerContext`, `useSpeechToText`, `useAudioLevel`, `useHeardText`, `useAIToolApprover` - Types: `AIMessageComposerStore`, `AIMessageComposerLayout`, `StreamingMessageRef`, `UseMessageTextStreamingProps`, `ToolComponentProps`, `UseSpeechToTextOptions`, `VoiceAgentState`, `VoiceGlowRendering`, `VoiceVisualizationProps`, `VoiceWaveProps`, `VoiceGlowProps`, `HeardTextProgress`, `HeardTextFunction`, `HeardTextSubscribe`, `UseHeardTextParams`, `UseHeardTextResult`, `AIMessage`, `AIMessageRole`, `AIPendingMessage`, `AIMessagePartData`, `AIReasoningPart`, `AIToolCallPart`, `AIToolApprovalData`, `AIToolAuthorizationData`, `AIToolApprover`, `AIToolAuthorizer`, `AIToolApprovalState`, `AIToolAuthorizationState`, `AIMessageAttachmentData`, `AIMessageRating`, `AIChatOption`, `PopupAuthorizerOptions`, `ConnectorSignInResult` @@ -112,19 +113,20 @@ const approver = { />; ``` -| Prop | Description | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `messages` | `AIMessage[]`, oldest first: `{ id, role, text, isGenerating?, attachments?, parts?, reasoningText?(stepId) }`. `role` is `user` or `assistant`. `fromStreamMessage(message, options?)` makes one from a Stream message: a reply has `custom.ai_generated: true` and is being written while `custom.generating: true`. | -| `pendingMessage` | `{ id, text, attachments? }`: a message the person sent that is not in `messages` yet. It shows at once, with the agent thinking under it. Keep passing it until its reply starts; its copy takes over its row (matched by `id` when it is the id you sent it with, else by where it lands). | -| `isThinking` | The agent is answering and its reply shows nothing yet. The latest turn then ends with `AIThinking`, whose seconds the reply's first reasoning step takes over. Also inferred while `pendingMessage` waits and while the latest reply is generating with nothing to show. | -| `approver` | Who answers tool calls' questions and sign-ins on this device. Also provided to the parts below through `AIToolApproverProvider`. | -| `loadOlderMessages` | Called once a spinner above the first turn has been in view for 300 ms, and again when the first turn changes while it is. What is on screen stays in place as older messages arrive. Pass `undefined` once there are none left. | -| `footer` | Rendered under the latest turn's messages, such as what the agent is doing (`AITypingIndicator`) or an error with a way to retry. | -| `emptyContent` | Shown, centred, while there are no messages. | -| `bottomInset` | Room in pixels under the latest turn for a composer floating over the conversation. The scroll-to-latest button sits above it. | -| `UserMessage`, `AssistantMessage`, `ReplyActions` | Slots for the person's messages, the agent's replies and the actions under each finished reply (`null` for none). Default to `AIUserMessage`, `AIAssistantMessage` and `AIReplyActions`. | -| `replyActions` | `(message, { isLatestReply }) => ({ rating?, onRate?, onRegenerate? })` for the default reply actions. | -| `ui` | An `AIUIHost`: shows the UI the agent writes for a reply (`AIMessage.uiDocument`) in place of its text. See [Generated UI](#generated-ui). | +| Prop | Description | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `conversation` | An `AIConversationController`: the conversation shows its state and offers what the person can do in it. It takes the place of `messages`, `pendingMessage`, `isThinking` and `loadOlderMessages`, and supplies `approver` and `replyActions` unless you pass them. See [`AIConversationController`](#aiconversationcontroller). | +| `messages` | Required without `conversation`. `AIMessage[]`, oldest first: `{ id, role, text, isGenerating?, rating?, attachments?, parts?, reasoningText?(stepId) }`. `role` is `user` or `assistant`. `fromStreamMessage(message, options?)` makes one from a Stream message: a reply has `custom.ai_generated: true` and is being written while `custom.generating: true`. | +| `pendingMessage` | `{ id, text, attachments? }`: a message the person sent that is not in `messages` yet. It shows at once, with the agent thinking under it. Keep passing it until its reply starts; its copy takes over its row (matched by `id` when it is the id you sent it with, else by where it lands). | +| `isThinking` | The agent is answering and its reply shows nothing yet. The latest turn then ends with `AIThinking`, whose seconds the reply's first reasoning step takes over. Also inferred while `pendingMessage` waits and while the latest reply is generating with nothing to show. | +| `approver` | Who answers tool calls' questions and sign-ins on this device. Also provided to the parts below through `AIToolApproverProvider`. | +| `loadOlderMessages` | Called once a spinner above the first turn has been in view for 300 ms, and again when the first turn changes while it is. What is on screen stays in place as older messages arrive. Pass `undefined` once there are none left. | +| `footer` | Rendered under the latest turn's messages, such as what the agent is doing (`AITypingIndicator`) or an error with a way to retry. | +| `emptyContent` | Shown, centred, while there are no messages. | +| `bottomInset` | Room in pixels under the latest turn for a composer floating over the conversation. The scroll-to-latest button sits above it. | +| `UserMessage`, `AssistantMessage`, `ReplyActions` | Slots for the person's messages, the agent's replies and the actions under each finished reply (`null` for none). Default to `AIUserMessage`, `AIAssistantMessage` and `AIReplyActions`. | +| `replyActions` | `(message, { isLatestReply }) => ({ rating?, onRate?, onRegenerate? })` for the default reply actions. | +| `ui` | An `AIUIHost`: shows the UI the agent writes for a reply (`AIMessage.uiDocument`) in place of its text. See [Generated UI](#generated-ui). | Behaviour: @@ -149,6 +151,111 @@ const AssistantMessage = (props: AIConversationAssistantMessageProps) => ( ); ``` +### `AIConversationController` + +The state behind a conversation, and what the person does in it: the counterpart of `AIConversation` in StreamChatAI (iOS) and of `AIConversationController` in `stream-chat-react-native`, with the same names, state and behaviour. Without it, every app works out `messages`, `pendingMessage`, `isThinking`, ratings and retries itself; with it, `AIConversation` and `AIMessageComposer` take one `conversation` prop. + +It reaches the agent through an `AIConversationTransport` of yours (or one an agent SDK ships): + +```ts +import { + type AIConversationTransport, + fromStreamMessage, +} from 'stream-chat-react/ai-components'; + +const transport: AIConversationTransport = { + // Tell the feed the messages each time they change, and what the agent is doing. + connect: (feed) => { + const update = () => + feed.updateMessages(messagesOf(channel).map((m) => fromStreamMessage(m))); + update(); + return channel.on(update).unsubscribe; + }, + // Resolve once the agent has the request; its reply arrives through the feed. + send: (request) => agent.respond(request), + stop: () => agent.interrupt(), + // Optional: + loadOlderMessages: () => loadOlderPage(), + rate: (reply, rating) => backend.rate(reply.id, rating), +}; +``` + +Make the controller inside a `useEffect` and `dispose()` it in that effect's cleanup. React's StrictMode runs effects twice in development, so a controller made with `useState(() => new AIConversationController(…))` and disposed in an effect is disposed by the first cleanup and never connected again; one made in the effect is made again with it. + +```tsx +import { + AIConversation, + AIConversationController, + AIMessageComposer, +} from 'stream-chat-react/ai-components'; + +const Chat = ({ transport }: { transport: AIConversationTransport }) => { + const [conversation, setConversation] = useState(); + useEffect(() => { + const controller = new AIConversationController({ approver, transport }); + setConversation(controller); + return () => controller.dispose(); + }, [transport]); + + if (!conversation) return null; + return ( + <> + } + /> + + + + + + + + + ); +}; +``` + +What it holds, in `conversation.state` (a `StateStore`; follow it with `useAIConversationState(conversation, selector)`, with the selector defined outside the component): + +| Field | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `messages` | The conversation as it shows, oldest first. While a reply is written again, or an edited message is answered again, the messages it replaces are left out until the new reply starts. | +| `pendingMessage` | The message the person sent last that is not in `messages` yet. It stays until its reply starts, and after it was stopped or failed, while `retry()` can send it again. | +| `status` | `ready`, `submitted` (on its way, or the agent works on it and its reply shows nothing yet), `streaming` or `error`. `isAIConversationAnswering(status)` is true for the middle two. | +| `error` | Why the latest request failed, or, as an `AIAgentError`, that the agent couldn't answer or didn't start its reply in time (`replyTimeout`, five minutes by default). | +| `activity` | What the agent says it is doing besides its reply, such as checking sources (`AIAgentActivityStates`). | +| `hasOlderMessages` | Whether `loadOlderMessages()` has anything to load. | +| `ratings` | How the person rated replies, by id: what they gave in the browser, or the reply's own `rating`. | + +What the person does: `send({ text, attachments?, chatOption?, custom? })` (a message, or the edit of the message being edited), `stop()`, `regenerate(reply)`, `edit(message)`, `cancelEditing()`, `retry()`, `clearError()`, `rate(reply, rating | null)` and `loadOlderMessages()`. A transport gets each as an `AIConversationRequest` with a `kind` (`message`, `regenerate`, `edit`, or one of your own), a `messageId` it can create the message with (a retry keeps it), the `text`, the `File`s picked, the `chatOption`, `custom`, and the `message` it is about. A generated UI's action travels as `custom.uiAction`. + +`actions: { edit, rate, regenerate }` turns off what the views offer (all by default). `composer` takes a store of your own (`createAIMessageComposerStore()`), and `approver` is given to `AIConversation` unless it gets one of its own. + +With a `conversation`: + +- `AIConversation` shows its messages, the message on its way and the agent thinking (status `submitted`), loads older messages while `hasOlderMessages`, offers rating under replies and regenerating the latest one while the agent isn't answering, and an edit button on the person's latest message. Passing `replyActions` replaces the controller's. +- `AIMessageComposer` shows `conversation.composer`, and submitting sends what the person wrote, the files they picked and the chat option (which stays picked), unless `onSubmit` calls `event.preventDefault()`. While the agent answers, `SubmitButton` turns into a `StopButton` that stops it, and Enter does not send. While the person edits a message the composer says so above the text ("Editing"), with a button that cancels it (`isEditing` on the store). + +An `AIAgentError` without `agentMessage` carries the English `AI_AGENT_ERROR_MESSAGE`; check for that to show copy of your own: + +```tsx +const errorSelector = ({ error }: AIConversationControllerState) => ({ error }); + +const ErrorFooter = ({ conversation }: { conversation: AIConversationController }) => { + const { error } = useAIConversationState(conversation, errorSelector); + if (!error) return null; + return ( +

+ {error instanceof AIAgentError && !error.agentMessage + ? 'The assistant is busy. Try again in a moment.' + : error.message} + +

+ ); +}; +``` + ### Generated UI An agent can answer with UI rather than text, such as a card, a form or a map, in a format such as [A2UI](https://a2ui.org) or OpenUI. The agents router puts it in the reply's `ai_ui` field (`custom.ai_ui` on a stream-chat v10 message) (`{ v, format, version?, status, content }`) and keeps the words around it as the reply's text, which shows wherever the UI can't. The counterpart of StreamChatAI's generative UI; these components know no format themselves, and renderers plug one in. @@ -286,7 +393,7 @@ const instructions = await openUIInstructions([placeCard, tripMap]); - `AIReplyActions`: copy, share (`navigator.share` where the browser has it, else a copy), regenerate (with `onRegenerate`) and a thumbs up or down rating (with `onRate`; `AIMessageRating` is `positive` or `negative`, and `null` takes a rating back). - `AISuggestions`: starter chips for an empty conversation; picking one calls `onSelect(text)`. -- `AIMessageComposer.ChatOptions`: a menu of ways to ask, such as "Think harder" (`AIChatOption`: `id`, `title`, `description?`, `icon?`, `shortTitle?`, `customData?`), and a chip for the one picked with a button that removes it. Controlled with `selectedId` and `onSelectedChange`, or uncontrolled with `defaultSelectedId`; with `name`, the picked id is submitted with the form. `ChatOptionsList` and `SelectedChatOptionChip` are its parts, for layouts of your own. +- `AIMessageComposer.ChatOptions`: a menu of ways to ask, such as "Think harder" (`AIChatOption`: `id`, `title`, `description?`, `icon?`, `shortTitle?`, `customData?`), and a chip for the one picked with a button that removes it. Controlled with `selectedId` and `onSelectedChange`, or uncontrolled with `defaultSelectedId`; with `name`, the picked id is submitted with the form. The picked option is kept in the composer's store as `chatOption`, where a `conversation` finds it when the message is sent, and stays picked after a form reset. `ChatOptionsList` and `SelectedChatOptionChip` are its parts, for layouts of your own. - `AIUserMessage`, `AIAssistantMessage`, `AIMessageAttachment` (an image thumbnail, or a file with its name, type and size) and `AITypingIndicator` (a label with three pulsing dots) are the views `AIConversation` uses, for lists of your own. ## Migrating from `@stream-io/chat-react-ai` @@ -655,8 +762,8 @@ By default (`layout='stacked'`) the parts are stacked, one under another: put th - **`AIMessageComposer.FileInput`** - File input button for attaching files. Supports multiple file selection. The native `` is visually hidden but stays focusable (it is the keyboard stop, labelled with the `fileUpload.uploadButton.fileUpload.ariaLabel` string, "File upload", and opens the picker on Enter/Space); the round `