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. AIConversation 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 doesn't need the message list of the Compose UI components.
AIConversation is available from version 7.16.0 of the stream-chat-android-ai-compose artifact. See Installation.
Show a conversation
Implement AIMessage on your own message type. A message needs an id, a role and its text. The other properties have defaults:
data class ChatMessage(
override val id: String,
override val role: AIMessageRole,
override val text: String,
override val isGenerating: Boolean = false,
) : AIMessageThen pass the messages to the conversation, oldest first, with your composer below it:
Column(modifier = Modifier.fillMaxSize()) {
AIConversation(
messages = messages,
modifier = Modifier.weight(1f),
)
ChatComposer(
onSendClick = { data -> send(data) },
onStopClick = { stop() },
isGenerating = isGenerating,
)
}Place the composer below the list rather than over it, so 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 properties:
| Property | Description | Default |
|---|---|---|
id |
The message's stable identity. | - |
role |
Who wrote the message: AIMessageRole.User, AIMessageRole.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 StreamingText. |
false |
parts |
The steps the agent took for it, in order. | emptyList() |
attachments |
What it carries besides its text, such as images and files. See Attachments. | emptyList() |
reasoningText(step) |
The whole of a reasoning step's thinking, when your app has it, such as from live updates. Otherwise the step's excerpt shows. | null |
reasoningText is called each time the conversation updates, so look the text up rather than work it out.
Messages from Stream Chat
The SDK has no ready-made AIMessage for a Stream Chat Message, so implement one in your app. The reply's steps and its generating field come from the message, as described in Reasoning and Tool Calls and Best practices:
class StreamAIMessage(
val message: Message,
currentUserId: String,
) : AIMessage {
override val id: String = message.id
override val role: AIMessageRole =
if (message.user.id == currentUserId) AIMessageRole.User else AIMessageRole.Assistant
override val text: String = message.text
override val isGenerating: Boolean = message.extraData["generating"] == true
// Stream Chat moves an attachment's `name` out of its extraData. A tool call's step needs it back.
override val parts: List<AIMessagePart> = AIMessagePart.parts(
message.attachments.map { it.type.orEmpty() to it.extraData + ("name" to it.name) },
)
override val attachments: List<AIMessageAttachment> =
message.attachments.mapNotNull { it.toAIMessageAttachment() }
}
private fun Attachment.toAIMessageAttachment(): AIMessageAttachment? = when (type) {
AttachmentType.IMAGE -> imageUrl?.let { AIMessageAttachment.image(Uri.parse(it), name = name) }
AttachmentType.FILE -> assetUrl?.let {
AIMessageAttachment.file(Uri.parse(it), name = title ?: name, size = fileSize.toLong(), mimeType = mimeType)
}
else -> null
}The properties are read on every update while a reply streams, so the wrapper decodes the steps once, when it's created, rather than in a getter. The conversation hands your message back in the params of the component factory's message slots, so you can cast params.message to StreamAIMessage to read the rest of the Stream message.
A channel's state lists its messages oldest first, which is the order the conversation expects:
class ConversationViewModel(cid: String, currentUserId: String) : ViewModel() {
private val channelState = ChatClient.instance()
.watchChannelAsState(cid = cid, messageLimit = 30, coroutineScope = viewModelScope)
@OptIn(ExperimentalCoroutinesApi::class)
val messages: StateFlow<List<AIMessage>> = channelState
.filterNotNull()
.flatMapLatest { it.messages }
.map { messages -> messages.map { StreamAIMessage(it, currentUserId) } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}Show the agent thinking
Pass isThinking = true only while the agent is answering and its reply shows nothing yet. The latest turn then ends with AIThinking, "Thinking... 4s", counting from when the question was asked. Once the reply shows something, pass false: its first reasoning step 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. Passing true after that keeps "Thinking..." under the reply while it streams.
// The latest reply shows nothing yet: there is none, or it has no text, steps or attachments.
val replyShowsNothing = messages.lastOrNull()?.let { last ->
last.role == AIMessageRole.User ||
(last.text.isEmpty() && last.parts.isEmpty() && last.attachments.isEmpty())
} ?: true
AIConversation(
messages = messages,
isThinking = indicator != null && replyShowsNothing,
)Here indicator is the state from Drive the AI indicator from events, which stays set until ai_indicator.clear, while the answer streams too. The conversation also knows the agent is thinking while a reply is being written but shows nothing, and while a message the user sent is on its way, as described next. A reply joins the list once it shows something.
AIThinking works on its own too. Outside the conversation it counts 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 photos upload. Pass it as the pendingMessage, and it shows as theirs straight away:
val scope = rememberCoroutineScope()
var sending by remember { mutableStateOf<AIPendingMessage?>(null) }
AIConversation(
messages = messages,
pendingMessage = sending,
)
ChatComposer(
onSendClick = { data ->
val id = UUID.randomUUID().toString()
sending = AIPendingMessage(
id = id,
text = data.text,
attachments = data.attachments.map { uri ->
AIMessageAttachment.from(uri, mimeType = context.contentResolver.getType(uri))
},
)
scope.launch {
val attachments = data.attachments.map { uri -> context.attachmentToUpload(uri) }
channelClient.sendMessage(Message(id = id, text = data.text, attachments = attachments))
.await()
.onError { sending = null }
}
},
onStopClick = { stop() },
isGenerating = isGenerating,
)Keep passing it until its reply starts. The first message from the user that arrives after it takes its place, and the list doesn't move. Pass the id the message will have, as above, so the copy is found by its id rather than by where it lands: a copy that arrives before the conversation first shows the pending message is only found by its id. If sending fails, pass null again, or the message and "Thinking..." stay on screen.
Stream Chat uploads an attachment from a File, so copy each file the user picked to the cache first:
suspend fun Context.attachmentToUpload(uri: Uri): Attachment = withContext(Dispatchers.IO) {
val file = File(cacheDir, "upload-${UUID.randomUUID()}")
checkNotNull(contentResolver.openInputStream(uri)).use { input ->
file.outputStream().use { input.copyTo(it) }
}
Attachment(upload = file, mimeType = contentResolver.getType(uri))
}AIMessageAttachment.from picks an image or a file from the file's type. A photo picker or MediaStore content:// URI has no extension, so pass contentResolver.getType(uri) as its mimeType, or it shows as a file.
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 null once there are none left:
AIConversation(
messages = messages,
loadOlderMessages = if (hasOlderMessages) viewModel::loadOlderMessages else null,
)With Stream Chat, loadOlderMessages can call ChatClient.instance().loadOlderMessages(cid, messageLimit = 30).await(), and hasOlderMessages is the negation of the channel state's endOfOlderMessages.
Footer and empty state
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:
AIConversation(
messages = messages,
isThinking = isWaitingForReply,
footer = {
if (error != null) {
RetryButton(error = error, onRetry = retry)
}
},
emptyContent = {
Text("How can I help?")
},
)Customize the views
The conversation draws each message through ChatAiComponentFactory, which you provide with LocalChatAiComponentFactory. Every slot has a default, so override only the ones you want to change:
| Slot | Shows | Default |
|---|---|---|
AIConversationUserMessage |
A message the user sent, or one on its way (message is null). |
AIUserMessage |
AIConversationAssistantMessage |
A reply: its steps, its answer and its attachments. | AIAssistantMessage |
AIConversationReplyActions |
What the user can do with a finished reply. | Nothing |
AIConversationAttachment |
One attachment of a message. | AIMessageAttachmentItem |
The params of the user message, assistant message and reply actions slots carry your own message as message, so cast it to read what else it holds. AIConversationAttachment gets the attachment and the sender's role instead. To override some slots on top of the factory already provided, wrap the content in CompoundChatAiComponentFactory, as in Editing a message.
Reply actions
AIReplyActionsRow shows copy and share buttons under a reply, and regenerate and rate buttons when you handle them. The conversation shows nothing there by default, so return the row from AIConversationReplyActions:
class ReplyActionsFactory(
private val ratings: Map<String, AIMessageRating>,
private val onRate: (messageId: String, rating: AIMessageRating?) -> Unit,
private val onRegenerate: () -> Unit,
) : ChatAiComponentFactory {
@Composable
override fun AIConversationReplyActions(params: AIConversationReplyActionsParams) {
AIReplyActionsRow(
text = params.text,
actions = AIReplyActions(
rating = ratings[params.message.id],
onRegenerate = if (params.isLatestReply) onRegenerate else null,
onRate = { rating -> onRate(params.message.id, rating) },
),
modifier = params.modifier,
)
}
}val ratings = remember { mutableStateMapOf<String, AIMessageRating>() }
val factory = remember {
ReplyActionsFactory(
ratings = ratings,
onRate = { id, rating -> if (rating == null) ratings.remove(id) else ratings[id] = rating },
onRegenerate = { /* Ask your backend for a new reply. */ },
)
}
CompositionLocalProvider(LocalChatAiComponentFactory provides factory) {
AIConversation(messages = messages)
}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. Regenerate shows only when onRegenerate is set, and the thumbs only when onRate is set. onRate receives AIMessageRating.Positive or AIMessageRating.Negative, or null when the user taps their rating again to take it back. Keep the ratings in snapshot state, as above, so the row updates when one changes.
Editing a message
Pressing and holding the user's message offers to copy its text, and to edit it when AIUserMessage gets an onEdit. The default slot passes none, so override it. Here editing puts the text back in a composer whose message you own:
var draft by remember { mutableStateOf(MessageData()) }
Column {
CompoundChatAiComponentFactory(
factory = { current ->
object : ChatAiComponentFactory by current {
@Composable
override fun AIConversationUserMessage(params: AIConversationUserMessageParams) {
val message = params.message
AIUserMessage(
text = params.text,
attachments = params.attachments,
modifier = params.modifier,
onEdit = message?.let { { draft = MessageData(text = it.text) } },
)
}
}
},
) {
AIConversation(messages = messages, modifier = Modifier.weight(1f))
}
ChatComposer(
messageData = draft,
onMessageDataChange = { draft = it },
onSendClick = { data -> send(data) },
onStopClick = { stop() },
isGenerating = isGenerating,
)
}A message that is still on its way has no message, so it offers no edit. What sending the edited text does, such as updating the message and asking for a new reply, is up to your app.
Rows of your own
To show messages of any type your own way, use the overload that takes a key, says which messages the user sent (each of them starts a turn), and makes each row:
AIConversation(
messages = rows,
key = { it.id },
isFromUser = { it.isMine },
isThinking = isWaitingForReply,
) { row ->
MessageRow(row.text)
}The key must be stable, unique and saveable in a Bundle, as with any lazy list key. This overload doesn't take a pendingMessage or an approver, since your rows show the messages.
Attachments
An AIMessageAttachment is something a message carries besides its text. AIMessageAttachmentItem, the default, shows an image as a square thumbnail, and any other kind as a file card with its name, type and size. Tapping a file opens it in an app for its type, and a page on the web in the browser.
val attachments = listOf(
AIMessageAttachment.image(imageUri),
AIMessageAttachment.image(capturedPhoto),
AIMessageAttachment.file(reportUri, name = "Report.pdf", size = 2_400_000),
)image takes a URI on the web or on the device, or an ImageBitmap your app holds, such as a photo the user just took. Pass a small bitmap, the size it shows at, since it stays in memory while the conversation shows it. AIMessageAttachment.from(uri, name, size, mimeType) picks the kind from the file's type, which suits the files the user picked in the composer. JPEG, PNG, WebP, GIF and BMP images show on every Android version, HEIF and HEIC from Android 9, and AVIF from Android 12. Other images, such as SVG, show as files.
The kinds of attachments are an open set, so a new one can appear without breaking your code. A kind the item doesn't know shows as a file. To show kinds of your own, keep what they need in extraData, and render them in AIConversationAttachment, with the default for the rest:
class LocationAttachmentFactory : ChatAiComponentFactory {
@Composable
override fun AIConversationAttachment(params: AIConversationAttachmentParams) {
if (params.attachment.kind == AIMessageAttachment.Kind("location")) {
LocationCard(extraData = params.attachment.extraData)
} else {
super.AIConversationAttachment(params)
}
}
}params.role says whether the user or the agent sent the message.
Tool calls
Pass an AIToolApprover as the approver, and the conversation asks the user about tool calls that wait for their approval. Reasoning and Tool Calls covers it.
AIConversation(messages = messages, approver = approver)Colors and defaults
The views take their colors from the current MaterialTheme. Each one has a Defaults object whose colors() (and icons(), where it has icons) you can change one value at a time:
AIConversation:colors = AIConversationDefaults.colors(...)withscrollButtonBackground,scrollButtonBorder,scrollButtonIconandloadingIndicator, andicons = AIConversationDefaults.icons(scrollToLatest = ...).AIUserMessage:AIUserMessageDefaults.colors(background, text)for the user's bubble.AIReplyActionsRow:AIReplyActionsDefaults.colors(icon), andAIReplyActionsDefaults.icons(...)withcopy,copied,share,regenerate,ratePositive,ratedPositive,rateNegativeandratedNegative.AIMessageAttachmentItem:AIMessageAttachmentDefaults.colors(...)withimagePlaceholder,background,border,iconBackground,icon,nameanddetails,AIMessageAttachmentDefaults.icons(...)withfile,pdf,image,audio,videoandarchive, and animageSizeof 180 dp by default.
AIConversation(
messages = messages,
colors = AIConversationDefaults.colors(
scrollButtonBackground = Color(0xFF1E1E2E),
scrollButtonIcon = Color.White,
),
)To change the colors of the user's bubble, the reply actions or the attachments inside the conversation, override their slot and pass the colors to the view there. The texts, such as "Scroll to latest", "Copy" or "Regenerate", are string resources whose names start with stream_ai_compose_conversation_, which you can override in your app's strings.xml.