Conversation State
AIConversationView shows a conversation, and AIComposerView takes what the user writes. Between them and your agent sits the state of the conversation: its messages, whether the agent is thinking or writing, the message on its way, and why the last request failed. AIConversation holds that state and acts on it. You tell it how to reach your agent with a transport, and give it to both views:
struct AssistantView: View {
@StateObject private var conversation: AIConversation<ChatAIMessage>
init(channel: ChatChannelController) {
_conversation = StateObject(
wrappedValue: AIConversation(transport: ChannelTransport(channel: channel))
)
}
var body: some View {
AIConversationView(conversation: conversation)
.safeAreaInset(edge: .bottom) {
AIComposerView(conversation: conversation)
}
}
}ChatAIMessage is the message type from Messages from Stream Chat, and ChannelTransport is written below. With these two views, the conversation:
- Shows the user's message as soon as they send it, and the agent thinking until its reply starts.
- Turns the composer's send button into a stop button while the agent answers, and stops the agent when it's tapped.
- Loads older messages as the user scrolls up.
- Offers to copy, share and rate each finished reply, and to regenerate the latest one.
- Offers to edit the user's latest message, which is then answered again.
- Keeps the error of a request that failed, so the user can try it again.
If you know useChat from Vercel's AI SDK on the web, AIConversation plays the same part in a SwiftUI app.
AIConversation is part of StreamChatAI, available from version 5.16.0 of the Stream Chat Swift SDK. See Installation.
Make a conversation
AIConversation is an ObservableObject on the main actor. Make one per conversation and keep it for as long as its screen shows, for example in a @StateObject. It connects to its transport straight away.
| Parameter | Description | Default |
|---|---|---|
transport |
How it reaches the agent and learns what the conversation holds. See Write a transport. | - |
composerViewModel |
What the user is writing, such as a view model with your chat options. | A new one |
approver |
Who answers the approvals and sign-ins that tool calls wait for. See Reasoning and Tool Calls. | nil |
actions |
What its views offer: .edit, .regenerate and .rate. See Actions. |
.all |
replyTimeout |
How many seconds it waits, once the agent has a request, for the reply to start or the agent to say what it's doing. After that, it shows an AIAgentError. While the agent says it's working, it waits as long as that takes. nil always waits. |
300 |
It publishes what the views show, so a view of your own that holds it updates with it:
| Property | Description |
|---|---|
messages |
The conversation as it shows, oldest message first. |
pendingMessage |
The message the user sent last that isn't in the conversation yet. It stays until its reply starts, or while it can be sent again. |
status |
Where the latest exchange is: .ready, .submitted, .streaming or .error. status.isAnswering is true for the middle two. |
error |
Why the latest request failed. It stays until the next request, or until a reply starts after all. |
activity |
What the agent says it's doing besides what its reply shows, such as checking external sources. |
hasOlderMessages |
Whether there are messages before the first one to load. |
ratings |
How the user rated replies, by the reply's id. |
editingMessage |
The user's message they're editing in the composer, if any. |
status is .submitted from the moment the user sends a message until the reply shows something, which is when the conversation view shows the agent thinking. It's .streaming while the agent writes the reply, and .ready again once the reply is finished or stopped.
The views call its methods, and you can call them too, for example from a button of your own: send(_:attachments:chatOption:), stop(), regenerate(_:), edit(_:), cancelEditing(), retry(), clearError(), rate(_:_:) and loadOlderMessages().
Show what the agent is doing and errors
The view's footer shows under the latest turn's messages, or under the empty content while there are none. With a conversation, the footer is made again each time the conversation changes, so it can read the conversation directly:
AIConversationView(conversation: conversation) {
if conversation.status.isAnswering, let text = conversation.activity?.text {
AITypingIndicatorView(text: text)
}
if let error = conversation.error {
HStack {
Text(error.localizedDescription)
Button("Try again") {
conversation.retry()
}
}
}
} emptyContent: {
Text("How can I help?")
}The error is one of two kinds:
- The request failed, such as when the message couldn't be sent. It's the error your transport's
send(_:)threw.retry()sends the same request again, with the same message id. - The agent couldn't answer. It's an
AIAgentError, when the agent reports the error state (AI_STATE_ERROR) or its reply doesn't start withinreplyTimeout. Itsmessageis what the agent said, if anything, and itslocalizedDescriptionfalls back to "The agent couldn't answer. Try again." (theconversation.error.agentkey).retry()asks the agent for the reply again, without sending the user's message twice.
clearError() forgets the error and the request that failed.
When the user stops the agent, what it wrote so far stays, and the reply counts as finished even while the channel still marks it as generating. A reply that showed nothing yet is hidden, and the conversation ignores what the agent says it's doing until the agent clears it.
Write a transport
The transport is the part that knows your agent. It follows what the conversation holds and tells the conversation through a feed, and it sends what the user asks:
| Requirement | Description | Default |
|---|---|---|
connect(_:) |
Starts telling the feed what the conversation holds. The conversation calls it once, when it's made. | - |
send(_:) |
Sends what the user asks. Return once the agent has the request: the reply arrives through the feed. A thrown error shows as the error. |
- |
stop() |
Stops the reply the agent is writing. | - |
loadOlderMessages() |
Loads the messages before the first one, which arrive through the feed. | Does nothing |
rate(_:_:) |
Tells your backend how the user rated a reply, or nil when they took their rating back. |
Kept on device |
The transport is @MainActor. Its AIConversationFeed has three updates, and holds the conversation weakly, so keep the feed for as long as the transport follows the conversation:
update(messages:)- the conversation's messages, oldest first, each time they change.update(hasOlderMessages:)- whether there are messages before the first one.update(activity:)- what the agent says it's doing, as anAIAgentActivity, ornilonce it stops saying. Itsstateuses the raw values of Stream Chat's AI indicator, such as.thinkingor.checkingExternalSources, and itstextis what the agent says about it.
isConnected tells whether the conversation is still there. Stop following what it holds once it isn't.
Requests
send(_:) receives an AIConversationRequest:
| Property | Description |
|---|---|
kind |
What the user asks for: .message, .regenerate or .edit. It's an open set, so treat a kind you don't know as a new message. |
text |
What the user wrote. When a reply is written again, the text of the message it answers. |
attachments |
The files the user picked, on the device, to upload with their message. |
chatOption |
The option the user picked in the composer, such as thinking harder. |
message |
The message it's about: the reply to write again, or the user's message they edited. |
messageID |
An id for the user's message. Create the message with it, so the conversation finds its copy by it. |
A retry 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.
While a .regenerate or an .edit is on its way, the conversation leaves out the messages it replaces. Once the new reply starts, it shows the messages as the feed has them again, so update the conversation on your backend before the new reply arrives:
- For a regenerate, remove the request's
messageand the messages after it, or write the new reply in place ofmessage, under its id. Without amessage, it asks for a reply to the latest message, which has none, such as after the agent couldn't answer it. - For an edit, change the user's
messageto say the request'stext, under its id, and remove the messages after it.
A transport over a Stream Chat channel
With Stream Chat, the conversation is a channel. This transport follows the channel's messages and its AI indicator events, sends what the user asks as a message in the channel, and stops the agent with an AIIndicatorStopEvent:
import Combine
import StreamChat
import StreamChatAI
@MainActor
final class ChannelTransport: AIConversationTransport {
private let channel: ChatChannelController
// The AI indicator events reach the client's events controller, not a channel's.
private let events: EventsController
private var feed: AIConversationFeed<ChatAIMessage>?
private var subscriptions: Set<AnyCancellable> = []
init(channel: ChatChannelController) {
self.channel = channel
events = channel.client.eventsController()
}
func connect(_ feed: AIConversationFeed<ChatAIMessage>) {
self.feed = feed
let needsSynchronizing = channel.state == .initialized
channel.messagesChangesPublisher.map { _ in () }
.merge(with: channel.statePublisher.map { _ in () })
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in MainActor.assumeIsolated { self?.channelChanged() } }
.store(in: &subscriptions)
events.allEventsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] event in MainActor.assumeIsolated { self?.received(event) } }
.store(in: &subscriptions)
if needsSynchronizing {
channel.synchronize()
}
channelChanged()
}
func send(_ request: AIConversationRequest<ChatAIMessage>) async throws {
let attachments = try request.attachments.map { url in
try AnyAttachmentPayload(
localFileURL: url,
attachmentType: AIMessageAttachment(url: url).kind == .image ? .image : .file
)
}
// A regenerate asks the question again, and an edit sends the new text as a new message.
try await withCheckedThrowingContinuation { (done: CheckedContinuation<Void, Error>) in
channel.createNewMessage(
messageId: request.messageID,
text: request.text,
attachments: attachments
) { result in
done.resume(with: result.map { _ in () })
}
}
}
func stop() async throws {
try await withCheckedThrowingContinuation { (done: CheckedContinuation<Void, Error>) in
channel.eventsController().sendEvent(AIIndicatorStopEvent(cid: channel.cid)) { error in
if let error {
done.resume(throwing: error)
} else {
done.resume()
}
}
}
}
func loadOlderMessages() async throws {
try await withCheckedThrowingContinuation { (done: CheckedContinuation<Void, Error>) in
channel.loadPreviousMessages { error in
if let error {
done.resume(throwing: error)
} else {
done.resume()
}
}
}
channelChanged()
}
private func channelChanged() {
// The controller lists its messages newest first.
feed?.update(messages: channel.messages.reversed().map(ChatAIMessage.init))
feed?.update(
hasOlderMessages: channel.state == .remoteDataFetched && !channel.hasLoadedAllPreviousMessages
)
}
private func received(_ event: Event) {
switch event {
case let event as AIIndicatorUpdateEvent where event.cid == channel.cid:
feed?.update(activity: AIAgentActivity(
state: AIAgentActivity.State(rawValue: event.state.rawValue),
text: event.aiMessage
))
case let event as AIIndicatorClearEvent where event.cid == channel.cid:
feed?.update(activity: nil)
default:
break
}
}
}It follows the channel through the controller's publishers, so the controller's delegate stays free for the rest of your app. A few things to keep in mind:
createNewMessagecompletes once the message is saved on the device, and the SDK sends it to the channel after that. To show a message that couldn't be sent as the conversation'serror, wait insend(_:)until the message'slocalStateisnil(sent) or.sendingFailed, and throw for the latter.- A channel has no way to write a reply again or to edit a message, so this transport asks the question again for a regenerate, and sends an edit as a new message. The messages they replace come back once the new reply starts. If your backend can't remove or replace them, leave those actions out:
AIConversation(transport: transport, actions: [.rate]). - Ratings stay on the device unless the transport implements
rate(_:_:). To show the ratings your backend keeps, return them from your message'srating, which the conversation shows until the user rates the reply on the device.
For an agent with an API of its own, follow its messages the same way, and send the requests to it, where .regenerate and .edit can replace the messages they're about.
Actions
The conversation's views offer what its actions allow:
- Under each finished reply, an
AIReplyActionsView: copy and share (iOS 16 and later) while the reply has text, rate (.rate), and, while the agent isn't answering, regenerate on the latest reply (.regenerate). - On the user's latest message, while the agent isn't answering, pressing the bubble offers to edit it (
.edit). The message goes back into the composer, which shows "Editing message" with a button that cancels it, and the next message the user sends is an.editrequest.
The actions reach the factory methods of your own views through their options. AIReplyActionsViewOptions.actions holds the reply's AIReplyActions, with the user's rating and the closures that regenerate and rate it, and is nil only when the list doesn't show an AIConversation. AIUserMessageViewOptions.onEdit puts the message back in the composer, and is nil when the message can't be edited. For example, to add a button of your own next to the conversation's:
final class AssistantViewFactory: AIConversationViewFactory {
func makeReplyActionsView(options: AIReplyActionsViewOptions) -> some View {
HStack(spacing: 0) {
if let actions = options.actions {
AIReplyActionsView(text: options.text, actions: actions)
}
ReadAloudButton(text: options.text)
}
}
}
AIConversationView(conversation: conversation, viewFactory: AssistantViewFactory())The Conversation View guide lists the factory's other methods.
Generative UI
To show the UI your agent writes in place of a reply's text, pass the conversation's AIUIHost as ui:
AIConversationView(conversation: conversation, ui: ui)
.safeAreaInset(edge: .bottom) {
AIComposerView(conversation: conversation)
}Everything else stays as it is: the actions show under each finished reply, also one that answers only with UI, the user's latest message offers to edit it, and the footer and emptyContent work the same. To change the views too, pass a factory: AIConversationView(conversation: conversation, ui: ui, viewFactory: AssistantViewFactory()). A reply whose UI none of the host's renderers reads shows its text.
The thinking indicator gives way to a reply's UI as soon as it shows. The conversation's status can't tell a reply from its UI alone, so it stays .submitted until the reply has text, steps or attachments, or is finished. Keep that in mind in a footer that reads the status.