Conversation View

An AI assistant lays out a conversation differently from a chat. The conversation starts at the top and grows down, and when the user asks something, their question moves to the top of the screen while the reply writes itself out below it. AIConversationView gives you that layout. It shows the user's messages and the agent's replies with the AI components, including their reasoning, tool calls and attachments, and it's lighter than the message list of StreamChatSwiftUI, which it doesn't need.

Info:

AIConversationView is part of StreamChatAI, available from version 5.15.0 of the Stream Chat Swift SDK. See Install the SDKs.

Show a conversation

Conform your messages to AIMessage. A message needs an id, a role and its text. The other requirements have defaults:

struct Message: AIMessage {
    var id: String
    var role: AIMessageRole
    var text: String
    var isGenerating = false
}

Then pass them to the view, oldest first, with your composer below it:

VStack(spacing: 0) {
    AIConversationView(messages: messages)
    AIComposerView { message in
        send(message)
    }
}

You can also place the composer over the list with safeAreaInset(edge: .bottom), so the conversation scrolls under it. Either way, the list knows how much of it is visible.

Each message from the user starts a turn: that message and the replies after it. The latest turn is at least as tall as the list, so a new question can always scroll to the top, and the reply grows below it without moving anything on screen. A conversation that loads, or replaces the one shown, opens at its end. Once the user scrolls away from the end, a button brings it back into view.

AIMessage has these requirements:

Requirement Description Default
role Who wrote the message: .user, .assistant, or a role of your own, which shows as the agent's. -
text What the message says. A reply's text is Markdown. -
isGenerating Whether the agent is still writing it. While it is, the reply types itself out, as with StreamingMessageView. false
parts The steps the agent took for it, in order. []
attachments What it carries besides its text, such as images and files. See Attachments. []
uiDocument The UI the agent wrote for a reply, which shows in place of its text. See Generative UI. nil
rating How the user rated a reply, as your backend keeps it. See Conversation State. nil
reasoningText(for:) The whole of a reasoning step's thinking, when your app has it, such as from live updates. Otherwise the step's excerpt shows. nil

Messages from Stream Chat

To show a Stream Chat channel, wrap its messages. The reply's steps and its generating field come from the message, as described in Reasoning and Tool Calls and What your backend sends:

struct ChatAIMessage: AIMessage {
    let message: ChatMessage

    var id: MessageId { message.id }
    var role: AIMessageRole { message.isSentByCurrentUser ? .user : .assistant }
    var text: String { message.text }
    var isGenerating: Bool { message.extraData["generating"]?.boolValue == true }

    var parts: [AIMessagePart] {
        AIMessagePart.parts(from: message.allAttachments.map { ($0.type.rawValue, $0.payload) })
    }

    var attachments: [AIMessageAttachment] {
        message.imageAttachments.map { .image($0.imageURL) }
            + message.fileAttachments.map { .file($0.assetURL, name: $0.title, size: $0.file.size) }
    }
}

A channel controller lists its messages newest first, so reverse them:

AIConversationView(
    messages: channelController.messages.reversed().map(ChatAIMessage.init)
)

Let a conversation drive it

The view shows what you pass it. Something still has to tell it when the agent is thinking, which message is on its way and whether older messages can load, and act on what the user does: send, stop, regenerate, rate or edit. AIConversation does all of that, from a transport you write for your agent, and both views follow it:

AIConversationView(conversation: conversation)
    .safeAreaInset(edge: .bottom) {
        AIComposerView(conversation: conversation)
    }

The Conversation State guide covers it, with a transport over a Stream Chat channel. The rest of this page describes the view's own inputs, for when you drive the view yourself, and how to customize it either way.

Show the agent thinking

Before a reply shows anything, pass isThinking: true. The latest turn then ends with an AIThinkingView, "Thinking... 4s", counting from when the question was asked. Once the reply's first reasoning step arrives, it takes over the same timer, so the count goes on without resetting, and the step folds into "Thought for 8s" when the model is done. Reasoning that arrives already finished shows open, then folds before the answer types out.

AIConversationView(
    messages: messages,
    isThinking: isWaitingForReply
)

With Stream Chat, the agent is thinking from the moment your backend sends AI_STATE_THINKING until the reply starts, as described in Say what the agent is doing. The list also knows the agent is thinking while a reply is being written but shows nothing yet, and while a message the user sent is on its way, as described next.

AIThinkingView works on its own too. Outside the conversation view it counts from its since date, or from when it appears.

Show a message while it's sent

A message the user sends can take a moment to reach the conversation, for example while its channel is created or its photos upload. Pass it as the pendingMessage, and it shows as theirs straight away:

AIConversationView(
    messages: messages,
    pendingMessage: sending
)

AIComposerView { message in
    sending = AIPendingMessage(
        text: message.text,
        attachments: message.attachments.map { AIMessageAttachment(url: $0) }
    )
    send(message)
}

Keep passing it until its reply starts. The first message from the user that arrives after it takes its place, with its attachments, and the list doesn't move. If you know the id the message will have, pass it as the pending message's id, so the copy is found by its id rather than by where it lands.

Load older messages

Pass loadOlderMessages to load the messages before the first one. The list calls it when the user scrolls to its start, and shows a spinner meanwhile. The messages on screen stay in place as the older ones arrive. Pass nil once there are none left:

AIConversationView(
    messages: messages,
    loadOlderMessages: hasOlderMessages ? { await loadOlderMessages() } : nil
)

The footer shows under the latest turn's messages, for example what the agent is doing, or an error with a way to retry. The emptyContent shows, centered, while there are no messages:

AIConversationView(
    messages: messages,
    isThinking: isWaitingForReply,
    footer: {
        if let error {
            RetryButton(error: error)
        }
    },
    emptyContent: {
        Text("How can I help?")
    }
)

Suggestions

SuggestionsView offers conversation starters, a row of questions the user can tap to send. Show it as the empty content:

AIConversationView(conversation: conversation, emptyContent: {
    SuggestionsView(suggestions: ["Plan a trip", "Summarize my week"]) { message in
        conversation.send(message)
    }
})

Its closure receives a MessageData with the suggestion's text. You can also pass the height of the row (100 by default) and the itemMaxWidth of a suggestion (160 by default).

Put a menu beside it

SidebarView puts a side menu, such as the list of the user's conversations, next to the conversation. The user opens it by dragging from the leading edge, and closes it by dragging back or by tapping the dimmed content. Bind isOpen to open and close it from code, for example from a toolbar button:

@State private var isSidebarOpen = false

SidebarView(isOpen: $isSidebarOpen, excludedBottomHeight: 80) {
    ConversationList()
} content: {
    AssistantView()
}

The menu takes splitWidthRatio of the width (0.82 by default), and a drag opens it only when it starts within edgeActivationWidth points of the leading edge (32 by default). Drags that start in the bottom excludedBottomHeight points of the content, such as on the composer, are left to the content. The Personal assistant use case lists the user's conversations in it.

Customize the messages

The conversation view shows its messages with the views of an AIConversationViewFactory. Every method has a default, so override only the ones you want to change:

Factory method Shows Default
makeUserMessageView(options:) A message the user sent. AIUserMessageView
makeAssistantMessageView(options:) A reply: its steps, its answer and its attachments. AIAssistantMessageView
makeReplyActionsView(options:) What the user can do with a finished reply, under it. Nothing, or AIReplyActionsView with an AIConversation
makeAttachmentView(options:) One of a message's attachments. AIMessageAttachmentView
makeToolAuthorizationView(options:) The sign-in a tool call waits for. AIToolAuthorizationCard

Pass your factory to the view:

AIConversationView(messages: messages, viewFactory: MyFactory(assistant: assistant))

Each method's options carry what the view shows, and the message views' options carry your message too. It's one of your own messages, so cast it to read what else it holds.

AIUserMessageView shows the message's attachments, then its text in a bubble at the trailing edge. Pressing the bubble offers to copy the text, and to edit it when it has an onEdit closure. With an AIConversation, the options' onEdit puts the user's latest message back in the composer, and the default view passes it on. Without one, pass your own, for example to put the text back in the composer:

func makeUserMessageView(options: AIUserMessageViewOptions) -> some View {
    AIUserMessageView(viewFactory: self, text: options.text, attachments: options.attachments) { [composerViewModel] in
        composerViewModel.text = options.text
        composerViewModel.isTextFieldFocused = true
    }
}

AIAssistantMessageView shows the reply's steps with AIMessagePartsView, then its answer with StreamingMessageView, then its attachments. You can use both views outside the conversation view as well.

Reply actions

An AIReplyActionsView is a row of buttons under a finished reply. While the reply has text, it offers to copy it, and to share it on iOS 16 and later. Give its AIReplyActions an onRegenerate closure to offer regenerating the reply, and rating and onRate to let the user rate it.

A conversation view that shows an AIConversation shows the row by default, with the conversation's actions, which its factory finds in options.actions. Otherwise there's nothing under a reply by default: return the row from makeReplyActionsView(options:), with actions of your own:

final class MyFactory: AIConversationViewFactory {
    let assistant: AssistantModel

    init(assistant: AssistantModel) {
        self.assistant = assistant
    }

    func makeReplyActionsView(options: AIReplyActionsViewOptions) -> some View {
        AIReplyActionsView(
            text: options.text,
            actions: AIReplyActions(onRegenerate: options.isLatestReply ? assistant.regenerate : nil)
        )
    }
}

Here AssistantModel stands for your app's own model of the conversation, a @MainActor class. The actions show once the reply's answer has finished writing itself out, also under a reply that answers with generated UI or attachments and has no text. isLatestReply tells the latest reply apart, for example to offer regenerating only that one. The row of a reply isn't made again while the reply shows the same, so read what changes, such as the user's rating, from an observable model in a view of your own, rather than capturing it in the factory. onRate receives .positive or .negative, or nil when the user takes their rating back.

Rows of your own

To show messages of any kind your own way, pass a closure that makes each row, and say which messages the user sent, since each of them starts a turn:

AIConversationView(messages: messages, isSentByCurrentUser: \.isSentByCurrentUser) { message in
    MessageRow(message: message)
        .equatable()
}

These rows are laid out again each time the messages change. Make the row's view Equatable and add .equatable(), as above, so only the message that changed is laid out again while a reply streams. The views that show AIMessages already do this.

Attachments

An AIMessageAttachment is something a message carries besides its text. AIMessageAttachmentView, the default, shows an image as a thumbnail, and any other kind as a file card with its name, type and size. Tapping a file opens it: a file on the device in a preview, and one on the web through the openURL environment action, which you can set to open it your own way.

let attachments: [AIMessageAttachment] = [
    .image(imageURL),
    .image(capturedPhoto),
    .file(reportURL, name: "Report.pdf", size: 2_400_000)
]

.image takes a URL on the web or on the device, or a UIImage your app holds, such as a photo the user just took. Pass a small one, the size it shows at, since it stays in memory while the conversation shows it. AIMessageAttachment(url:name:size:mimeType:) picks the kind from the file's type, which suits the files the user picked in the composer.

The kinds of attachments are an open set, so a new one can appear without breaking your code. A kind the view doesn't know shows as a file. To show kinds of your own, keep what they need in extraData, and return their view from makeAttachmentView(options:), with the default for the rest:

@ViewBuilder
func makeAttachmentView(options: AIMessageAttachmentViewOptions) -> some View {
    if options.attachment.kind == "location" {
        LocationCard(extraData: options.attachment.extraData)
    } else {
        AIMessageAttachmentView(attachment: options.attachment)
    }
}

Generative UI

Pass an AIUIHost as ui, and a reply whose uiDocument one of its renderers reads shows that UI in place of its text, such as a trip plan with a map and a form. What the user does in it goes back to the agent as their next message. The Generative UI guide covers the renderers, your own components and what to send.

AIConversationView(messages: messages, ui: ui)

A conversation that an AIConversation drives takes the host the same way, as AIConversationView(conversation: conversation, ui: ui). See Conversation State.

Tool calls

Pass an AIToolApprover as the approver, and the conversation asks the user about tool calls that wait for them: an approval, or a sign-in at a provider when the approver has an AIToolAuthorizer. Reasoning and Tool Calls covers both.

AIConversationView(messages: messages, approver: approver)

Appearance

The conversation view reads its colors, fonts and icons from AIAppearance, as described in Theming:

  • Colors: userMessageBackground and userMessageText for the user's bubble, messageAction for the reply actions, conversationLoadingIndicator for the spinner while older messages load, conversationScrollButtonBackground, conversationScrollButtonIcon and conversationScrollButtonBorder for the button that scrolls to the end, and attachmentImagePlaceholder, attachmentBackground, attachmentBorder, attachmentIcon, attachmentIconBackground, attachmentName and attachmentDetails for attachments, suggestionBackground and suggestionText for suggestions, and sidebarBackground and sidebarScrim for the side menu.
  • Fonts: userMessage, messageAction, conversationScrollButton, attachmentIcon, attachmentName, attachmentDetails and suggestion.
  • Icons: messageCopy, messageCopied, messageShare, messageRegenerate, messageEdit, messageRatePositive, messageRatedPositive, messageRateNegative and messageRatedNegative for the message actions, attachmentFile, attachmentPDF, attachmentImage, attachmentAudio, attachmentVideo and attachmentArchive for the types of files, and conversationScrollToLatest.

Its texts, such as "Scroll to latest", "Copy" or "Regenerate", go through AIAppearance.localizationProvider, under the conversation.*, message_actions.* and attachment.* keys.