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 doesn't need the message list of stream_chat_flutter.
AIConversationView is available from version 0.2.0 of stream_chat_flutter_ai. See Install the SDKs.
Show a conversation
Mix AIMessage into your own message type, or wrap one with it. A message needs an id, a role and its text. The other members have defaults:
class ChatMessage with AIMessage {
ChatMessage({
required this.id,
required this.role,
required this.text,
this.isGenerating = false,
});
@override
final String id;
@override
final AIMessageRole role;
@override
final String text;
@override
final bool isGenerating;
}Then pass the messages to the conversation, oldest first, with your composer below it:
Column(
children: [
Expanded(
child: AIConversationView(messages: messages),
),
ChatComposer(
controller: controller,
onSendPressed: send,
onStopPressed: stop,
),
],
)Place the composer below the list, or float it over the list and tell the list how much of it is covered. Either way, the list knows how much of it is visible. The list needs a bounded height, such as the Expanded above. Its padding sets the space around the messages, 16 on the sides and at the bottom by default.
Each message from the user starts a turn: that message and the replies after it. Messages before the user's first one, such as a greeting, make a turn of their own. 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 members:
| Member | 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 StreamingMessageView. |
false |
parts |
The steps the agent took for it, in order. | const [] |
attachments |
What it carries besides its text, such as images and files. See Attachments. | const [] |
uiDocument |
The UI the agent wrote for a reply, which shows in place of its text. See Generative UI. | null |
rating |
How the user rated a reply, as your backend keeps it. See Conversation State. | null |
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 rebuilds, so look the text up rather than work it out.
Messages from Stream Chat
stream_chat_flutter_ai doesn't depend on Stream Chat, so it has no ready-made AIMessage for a Stream Chat Message. Wrap one in your app, with both packages imported as described in Stream Chat integration. The role comes from the ai_generated field, as described in Mark the agent's replies, and the reply's steps from its attachments, as described in Reasoning and Tool Calls:
class StreamAIMessage with AIMessage {
StreamAIMessage(this.message)
// The offline database stores a step's `id` as `Attachment.id`, and the
// network keeps it in extraData, which is spread last so it wins.
: parts = AIMessagePart.parseAll([
for (final attachment in message.attachments)
if (attachment.rawType case final type?)
(type: type, payload: {'id': attachment.id, ...attachment.extraData}),
]),
attachments = [
for (final attachment in message.attachments)
?toAIMessageAttachment(attachment),
];
final Message message;
@override
String get id => message.id;
@override
AIMessageRole get role =>
message.extraData['ai_generated'] == true ? AIMessageRole.assistant : AIMessageRole.user;
@override
String get text => message.text ?? '';
@override
bool get isGenerating => message.extraData['generating'] == true;
@override
final List<AIMessagePart> parts;
@override
final List<AIMessageAttachment> attachments;
}
AIMessageAttachment? toAIMessageAttachment(Attachment attachment) => switch (attachment) {
Attachment(type: AttachmentType.image, imageUrl: final url?) =>
AIMessageAttachment.image(Uri.parse(url), name: attachment.title),
Attachment(type: AttachmentType.file, assetUrl: final url?) => AIMessageAttachment.file(
Uri.parse(url),
name: attachment.title,
size: attachment.fileSize,
mimeType: attachment.mimeType,
),
_ => null,
};The members are read on every rebuild while a reply streams, so the wrapper decodes the steps and attachments once, when it's created, rather than in a getter. The conversation hands your message back in the props of the component builders, so you can match props.message against 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. Keep only the regular ones, leaving out system, deleted and ephemeral messages:
StreamBuilder<List<Message>>(
stream: channel.state!.messagesStream,
initialData: channel.state!.messages,
builder: (context, snapshot) => AIConversationView(
messages: [
for (final message in snapshot.data!)
if (message.type == MessageType.regular) StreamAIMessage(message),
],
),
)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:
Column(
children: [
Expanded(child: AIConversationView.conversation(conversation: conversation)),
ChatComposer.conversation(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.
Float the composer over the list
To let the conversation scroll under the composer, put it in the bottomNavigationBar of a Scaffold with extendBody: true, and pass its height as the bottom of the list's padding:
Scaffold(
extendBody: true,
// Read below the Scaffold, where the bottom padding is the composer's height.
body: Builder(
builder: (context) => AIConversationView(
messages: messages,
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + MediaQuery.paddingOf(context).bottom),
),
),
bottomNavigationBar: Padding(
// The bar stays at the bottom of the screen, so lift it above the keyboard.
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SafeArea(
top: false,
child: ChatComposer(
controller: controller,
onSendPressed: send,
onStopPressed: stop,
),
),
),
)The messages scroll under the padding but stop clear of it. The latest turn is at least as tall as the part of the list the padding leaves visible, so a new question still scrolls to the top of what the user sees. The button that brings the end back into view sits just above the padding. When the padding changes while the end is in view, such as when the composer grows with a longer message, the list keeps the end in view. The padding replaces the list's own spacing, so keep its 16 on the sides and at the bottom, as above.
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 an AIThinkingView, "Thinking... 4s", counting from when the question was asked when you pass it as the pendingMessage, and otherwise from when isThinking turns true. 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.
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. This helper says whether the latest reply shows nothing yet:
// Whether the latest reply shows nothing yet: there is none, or it has no
// text, steps or attachments.
bool replyShowsNothing(List<AIMessage> messages) => switch (messages.lastOrNull) {
null => true,
final last =>
last.role == AIMessageRole.user ||
(last.text.isEmpty && last.parts.isEmpty && last.attachments.isEmpty),
};Combine the two:
AIConversationView(
messages: messages,
isThinking: switch (aiState) {
AITypingState.thinking || AITypingState.checkingSources => replyShowsNothing(messages),
_ => false,
},
)Here aiState is what the agent says it's doing, the AITypingState of the channel's latest ai_indicator.update, as the transport in Conversation State follows it. The conversation also knows the agent is thinking while the latest 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.
AIThinkingView works on its own too. Outside the conversation it counts from its since time, 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. Pass it as the pendingMessage, and it shows as theirs straight away. ChatComposer hands send the photos the user picked as XFiles:
AIPendingMessage? sending;
Future<void> send(String text, ChatOption? option, List<XFile> photos) async {
final picked = [
for (final photo in photos) (name: photo.name, bytes: await photo.readAsBytes()),
];
final message = Message(
text: text,
attachments: [
for (final photo in picked)
Attachment(
type: AttachmentType.image,
file: AttachmentFile(bytes: photo.bytes, name: photo.name, size: photo.bytes.length),
),
],
);
setState(() {
sending = AIPendingMessage(
id: message.id,
text: text,
attachments: [
for (final photo in picked)
AIMessageAttachment.memoryImage(
ResizeImage(MemoryImage(photo.bytes), width: 360),
name: photo.name,
),
],
);
});
try {
await channel.sendMessage(message);
} on Object {
if (mounted) setState(() => sending = null);
}
}Column(
children: [
Expanded(
child: AIConversationView(messages: messages, pendingMessage: sending),
),
ChatComposer(onSendPressed: send),
],
)Keep passing it until its reply starts, then set it back to null, for example once the latest message is the agent's. The first message from the user that arrives after it takes its place, keeping the pending message's attachments, and the list doesn't move. Pass the id the message will have, as above, so its 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.
channel.sendMessage adds the message to the channel's state before it uploads the photos, so with Stream Chat its copy arrives almost at once. The photos stay on screen from the pending message rather than loading again from their uploads. AIMessageAttachment.memoryImage keeps the image in memory while the conversation shows it, so pass a small one, the size it shows at, as ResizeImage does above.
Load older messages
Pass loadOlderMessages to load the messages before the first one. While you pass it, the list shows a spinner at its start, and calls it once the spinner comes into view: when the user scrolls to the start, or as soon as the conversation opens if the start is already on screen. The messages on screen stay in place as the older ones arrive. Pass null once there are none left:
AIConversationView(
messages: messages,
loadOlderMessages: hasOlderMessages ? loadOlderMessages : null,
)With Stream Chat, query the messages before the oldest one. They join the channel's state, and so the conversation. Start hasOlderMessages at true, and set it to false once a query returns fewer messages than its limit:
Future<void> loadOlderMessages() async {
const limit = 30;
final oldest = channel.state!.messages.firstOrNull;
if (oldest == null) return;
final state = await channel.query(
messagesPagination: PaginationParams(lessThan: oldest.id, limit: limit),
);
if (!mounted) return;
if ((state.messages?.length ?? 0) < limit) {
setState(() => hasOlderMessages = false);
}
}An error thrown by loadOlderMessages is reported to FlutterError.onError. The list doesn't retry until the first message changes, or the user scrolls away from the start and back.
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, with the footer under it:
AIConversationView(
messages: messages,
isThinking: isWaitingForReply,
footer: error == null ? null : RetryButton(error: error, onPressed: retry),
emptyContent: const Text('How can I help?'),
)Suggestions
AISuggestionsView offers conversation starters, a row of questions the user can tap. Show it as the empty content, and send the text the user taps:
AIConversationView(
messages: messages,
emptyContent: AISuggestionsView(
suggestions: const ['Plan a trip', 'Summarize my week'],
onSuggestionSelected: (text) => send(text, null, const []),
),
)The row scrolls sideways, and each chip shows its text on up to two lines, all at the same height. It sends nothing itself, so you can also put the text in the composer instead, as described in Control it from your code. You can also pass the itemMaxWidth of a chip (160 by default) and the row's padding.
Its colors and text style come from SuggestionsThemeData, on AITheme.suggestionsTheme or in a SuggestionsTheme scope, as described in Theming: backgroundColor, borderColor and textStyle. The chips default to the same tokens as the composer's input, since they're normally docked right above it, so they're outlined rather than filled. Set backgroundColor to fill them.
Customize the messages
The conversation builds its messages through the builders registered with streamAIComponentBuilders in the nearest StreamComponentFactory, the same factory that replaces the package's other components, as described in Replacing components. Every builder is nullable, so set only the ones you want to change:
| Builder | Shows | Props | Default |
|---|---|---|---|
userMessage |
A message the user sent, or one on its way (message is null). |
AIUserMessageProps |
AIUserMessageView |
assistantMessage |
A reply: its steps, its answer and its attachments. | AIAssistantMessageProps |
AIAssistantMessageView |
replyActions |
What the user can do with a finished reply, under it. | AIReplyActionsProps |
Nothing, or AIReplyActionsView with an AIConversation |
messageAttachment |
One attachment of a message, in either kind of message. | AIMessageAttachmentProps |
AIMessageAttachmentView |
toolAuthorization |
The sign-in a tool call waits for. | AIToolAuthorizationProps |
AIToolAuthorizationCard |
AIUserMessagePropshas themessage, itstext,attachments,onAttachmentTapandonEdit.AIAssistantMessagePropshas themessage, itstext,isGenerating,partsandattachments, theapproverandonAttachmentTap.AIReplyActionsPropshas the reply'smessage, itstext,isLatestReplyandactions.AIMessageAttachmentPropshas theattachment,isFromUserandonTap.AIToolAuthorizationPropsis described in Tool sign-ins.
message is one of your own messages, so match it against your type to read what else it holds. Here each reply shows the name of the user who sent it above the default view:
// In your State: created once, not in build.
late final builders = streamAIComponentBuilders(
assistantMessage: (context, props) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (props.message case StreamAIMessage(:final message))
Text(message.user?.name ?? '', style: context.streamTextTheme.captionEmphasis),
AIAssistantMessageView(
text: props.text,
isGenerating: props.isGenerating,
parts: props.parts,
attachments: props.attachments,
approver: props.approver,
reasoningText: props.message.reasoningText,
onAttachmentTap: props.onAttachmentTap,
),
],
),
);
late final componentBuilders = StreamComponentBuilders(extensions: builders);
@override
Widget build(BuildContext context) {
return StreamComponentFactory(
builders: componentBuilders,
child: AIConversationView(messages: messages),
);
}AIUserMessageView shows the message's attachments, then its text in a bubble at the trailing edge. AIAssistantMessageView shows the reply's steps with AIMessagePartsView, then its answer with StreamingMessageView, then its attachments. You can use both views outside the conversation as well.
The conversation caches its rows. A row is built again only when what its AIMessage says changes, or the builders do, so a streaming reply doesn't rebuild the rest of the conversation. Two rules follow for builders that show state of your own, such as a reply's rating or a message's send status:
- Read that state through a
Listenable, with aListenableBuilderorValueListenableBuilderin what the builder returns. A value captured directly isn't read again when it changes, so a tapped thumbs up wouldn't fill. - Create the builders and their
StreamComponentBuildersonce, such as inlate finalfields of yourState, as above, not inbuild. A newStreamComponentBuildersrebuilds every row whenever your widget rebuilds, such as on every update of a streaming reply. The same goes for theapproverandonAttachmentTapyou pass toAIConversationView.
The nearest StreamComponentFactory wins: a nested one replaces its parent's builders rather than adding to them. To keep your app's other builders, such as the Stream Chat ones, list them in the same extensions list.
Reply actions
AIReplyActionsView is a row of buttons under a finished reply. While the reply has text, it offers to copy and share it. Give its AIReplyActions an onRegenerate callback 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 replyActions builder finds in props.actions. Otherwise there's nothing under a reply by default: return the row from the replyActions builder, with actions of your own. The rating changes without the reply changing, so read it through a ValueNotifier:
// In your State:
final ratings = ValueNotifier<Map<String, AIMessageRating>>({});
late final builders = streamAIComponentBuilders(
replyActions: (context, props) => ValueListenableBuilder(
valueListenable: ratings,
builder: (context, rated, _) => AIReplyActionsView(
text: props.text,
actions: AIReplyActions(
rating: rated[props.message.id],
onRate: (rating) => rate(props.message, rating),
onRegenerate: props.isLatestReply ? regenerate : null,
),
),
),
);
void rate(AIMessage reply, AIMessageRating? rating) {
ratings.value = {
for (final MapEntry(:key, :value) in ratings.value.entries)
if (key != reply.id) key: value,
reply.id: ?rating,
};
// Send the rating to your backend.
}The actions show once the reply is finished and its answer has typed itself out, also under a reply that answers with generative UI or attachments and has no text, once its UI is written. With an assistantMessage builder that doesn't use AIAssistantMessageView, they show as soon as the reply stops generating. isLatestReply tells the latest reply apart, for example to offer regenerating only that one.
- Regenerate shows only when
onRegenerateis set. With anAIConversation, it asks the agent to write the reply again. Without one, what it does, such as asking your backend for a new reply, is up to your app. - Rate shows a thumbs up and a thumbs down only when
onRateis set.onRatereceivesAIMessageRating.positiveorAIMessageRating.negative, ornullwhen the user taps their rating again to take it back. Pass the rating given asrating, and its thumb fills.
Editing a message
Pressing and holding the user's message, or right-clicking it, offers to copy its text, and to edit it when AIUserMessageView gets an onEdit. With an AIConversation, the props' onEdit puts the user's latest message back in the composer, and the default builder passes it on. Without one, the default builder passes none, so set userMessage. Here editing puts the text back in the composer:
final composer = ChatComposerController();
late final builders = streamAIComponentBuilders(
userMessage: (context, props) => AIUserMessageView(
text: props.text,
attachments: props.attachments,
onAttachmentTap: props.onAttachmentTap,
onEdit: props.message == null
? null
: () => composer.textEditingController.text = props.text,
),
);
@override
void dispose() {
composer.dispose();
super.dispose();
}Pass the same composer to your ChatComposer as its controller. To put the cursor in the field too, call composer.requestFocus() in onEdit, as described in Control it from your code. A message that is still on its way has no message, so it offers no edit. Without a conversation, what sending the edited text does, such as updating the message and asking for a new reply, is up to your app.
Attachments
An AIMessageAttachment is something a message carries besides its text. AIMessageAttachmentView, the default, shows an image as a square thumbnail, 180 wide unless you set its imageSize, 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:
final attachments = [
AIMessageAttachment.image(imageUrl),
AIMessageAttachment.memoryImage(ResizeImage(MemoryImage(photoBytes), width: 360)),
AIMessageAttachment.file(reportUrl, name: 'Report.pdf', size: 2400000),
];AIMessageAttachment.imageloads an image from a URL.AIMessageAttachment.memoryImagetakes anImageProvideryour 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.filetakes the file's URL, and optionally itsname,sizein bytes andmimeType. The card shows thename, or else the last segment of the URL. Its type is the extension of the name or URL, or else the subtype ofmimeType, such asPDF.
To open attachments your own way, pass onAttachmentTap to the conversation:
void openAttachment(AIMessageAttachment attachment) {
if (attachment.url case final url?) FilePreview.show(context, url);
}
@override
Widget build(BuildContext context) {
return AIConversationView(
messages: messages,
onAttachmentTap: openAttachment,
);
}Here FilePreview is a widget of your app.
A method of your State, as above, is the same function on every build, so the rows aren't built again. A closure written in build is a new one each time.
The kinds of attachments, AIMessageAttachmentKind, are an open set, so a new one can appear without breaking your code. AIMessageAttachmentView shows any kind but image as a file, and an image with neither a url nor an image provider as a file too. To show kinds of your own, such as a location, keep what they need in extraData. With Stream Chat, add a case for the kind to toAIMessageAttachment from Messages from Stream Chat:
Attachment(type: 'location') => AIMessageAttachment(
kind: const AIMessageAttachmentKind('location'),
extraData: attachment.extraData,
),Then render the kind in the messageAttachment builder, and return the default view for every other kind:
late final builders = streamAIComponentBuilders(
messageAttachment: (context, props) => switch (props.attachment.kind) {
'location' => LocationCard(extraData: props.attachment.extraData),
_ => AIMessageAttachmentView(attachment: props.attachment, onTap: props.onTap),
},
);props.isFromUser says whether the user or the agent sent the message. extraData compares by value, so an attachment decoded again on the next update doesn't rebuild its row.
Generative UI
Pass an AIUIHost as ui. When one of the host's renderers can read a reply's uiDocument, the view shows that UI in place of the reply's text, after its steps, 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, through the host's send. The Generative UI guide covers the renderers, your own components and what to send.
AIConversationView(messages: messages, ui: ui)Keep the same host for as long as the conversation shows, so each reply's UI keeps its state. A reply whose UI none of the host's renderers reads shows its text.
A conversation that an AIConversation drives takes the host the same way, as AIConversationView.conversation(conversation: conversation, ui: ui). See Conversation State.
The thinking indicator gives way to a reply's UI as soon as it shows. If you compute isThinking with a helper such as replyShowsNothing from Show the agent thinking, count a reply with a uiDocument as showing something too. The reply actions show under a reply that answers only with UI, once its UI is written, and the footer and emptyContent work the same.
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)Create the approver once, for example in your State, since a new one rebuilds every row.
Appearance
The conversation takes its colors from the Stream theme, StreamTheme. To change them, set conversationTheme and replyActionsTheme on the AITheme extension, as described in Theming:
MaterialApp(
theme: ThemeData(
extensions: const [
AITheme(
conversationTheme: ConversationThemeData(
userMessageBackgroundColor: Color(0xFFE8F0FF),
scrollButtonIconColor: Color(0xFF005FFF),
),
replyActionsTheme: ReplyActionsThemeData(
selectedIconColor: Color(0xFF005FFF),
),
),
],
),
home: const ConversationScreen(),
)ConversationThemeData covers AIConversationView, AIUserMessageView and AIMessageAttachmentView. Every field is nullable, and a field left unset follows the StreamTheme:
| Field | Styles | Default |
|---|---|---|
userMessageBackgroundColor |
The user's bubble. | backgroundSurface |
userMessageTextStyle |
The text in the user's bubble, merged onto the default. | bodyDefault in textPrimary |
attachmentBackgroundColor |
A file card, and an image while it loads or when it fails to. | backgroundSurface |
attachmentTitleStyle |
A file card's name, merged onto the default. | bodyEmphasis in textPrimary |
attachmentDetailsStyle |
A file card's type and size, merged onto the default. | captionDefault in textSecondary |
scrollButtonBackgroundColor |
The button that scrolls to the latest message. | the secondary outline button's style |
scrollButtonIconColor |
That button's arrow, and the spinner while older messages load. | the button's foreground, and textPrimary for the spinner |
scrollButtonBorderColor |
That button's border. | the secondary outline button's style |
ReplyActionsThemeData covers AIReplyActionsView:
| Field | Styles | Default |
|---|---|---|
iconColor |
The buttons' icons. | textSecondary |
selectedIconColor |
The thumb of the rating given. | iconColor |
iconSize |
The size of the icons. | 18 |
To theme one conversation only, wrap it in a ConversationTheme, or the reply actions in a ReplyActionsTheme:
ConversationTheme(
data: const ConversationThemeData(userMessageBackgroundColor: Colors.teal),
child: AIConversationView(messages: messages),
)A nested scope replaces the enclosing one. Use ConversationTheme.merge or ReplyActionsTheme.merge to add to it instead.
Its texts, such as "Scroll to latest", "Copy" or "Regenerate", are members of AITranslations, which you translate as described in Localization.