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 Installation.

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. []
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 Best practices:

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)
)

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: indicatorHandler.indicatorText != nil
)

Here indicatorHandler is the handler from Drive the AI indicator from events. 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?")
    }
)

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
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(chat: chat))

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 you pass onEdit, 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.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

There's nothing under a finished reply by default. Return an AIReplyActionsView from makeReplyActionsView(options:) to add a row of buttons. It always offers to copy the reply, 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:

final class MyFactory: AIConversationViewFactory {
    let chat: Chat

    init(chat: Chat) {
        self.chat = chat
    }

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

The actions show once the reply's answer has finished writing itself out. 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)
    }
}

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 Customizing the appearance:

  • 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.
  • Fonts: userMessage, messageAction, conversationScrollButton, attachmentIcon, attachmentName and attachmentDetails.
  • 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.