Stream Chat Integration

The AI components work seamlessly with the stream_chat_flutter SDK. Add both packages to your app, since stream_chat_flutter_ai doesn't depend on Stream Chat:

dependencies:
  stream_chat_flutter: ^10.6.0
  stream_chat_flutter_ai: ^0.2.0

Together they need Dart 3.12 or later and Flutter 3.44 or later, as described in the installation section. To let users attach photos or dictate messages, also add the permissions described in Composer.

This page adds the AI components to the stream_chat_flutter message list. If your app is an AI assistant rather than a chat, AIConversationView lays out the whole conversation for you, without the message list.

stream_chat_flutter ships older versions of some AI widgets. When you import both packages, hide those names so the ones from stream_chat_flutter_ai are used:

import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    hide
        StreamingMessageView,
        AITypingIndicatorView,
        TypewriterState,
        TypewriterValue,
        TypewriterController,
        TypewriterWidgetBuilder;
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';

Rendering AI messages

StreamMessageListView accepts a messageBuilder. Use it to render messages marked with ai_generated with a StreamingMessageView, and keep the regular bubble for everything else:

StreamMessageListView(
  messageBuilder: (context, message, defaultProps) {
    if (message.extraData['ai_generated'] == true) {
      return AIMessageItem(key: ValueKey(message.id), message: message);
    }
    return DefaultStreamMessageItem(props: defaultProps);
  },
);

Key the item by the message's id, as the list does with its own items. Loading older messages shifts every item's position, and without the key a reply's typing state can move to a different message.

AIMessageItem renders the message text without a bubble or avatar. Every time the backend updates the message, StreamingMessageView receives the longer text and types the new part out. The backend sets the message's generating field to true while the reply streams and to false on its final update, which is still typed out to the end, so a live reply types itself out while one loaded from history shows in full at once:

class AIMessageItem extends StatelessWidget {
  const AIMessageItem({
    super.key,
    required this.message,
  });

  final Message message;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: StreamingMessageView(
        text: message.text ?? '',
        expectMoreText: message.extraData['generating'] == true,
        codeHighlighter: highlightCode,
        onTapLink: (text, href, title) => openLink(href),
      ),
    );
  }
}

highlightCode and openLink are helpers you define yourself: highlightCode is the syntax highlighter described in Code and charts, and openLink opens the URL with a launcher such as url_launcher. The sample app shows both wired up. The codeHighlighter, mathBuilder and link handling are all optional. The markdown takes its styles from the same StreamTheme as the Stream Chat widgets, so it matches the regular message bubbles without a styleSheet. See Mark the agent's replies for how the backend sets the flag.

An item of your own replaces the whole default message item, so AI replies also have no reactions or reply counts, and it leaves out the default message footer, which would mark every AI reply as "Edited": each update the backend makes to the text sets the message's messageTextUpdatedAt, and the footer shows "Edited" whenever it's set.

The backend's placeholder message is empty until the first update arrives, so the item has nothing to type out yet. Show what the agent is doing meanwhile with the AI indicator, as described in Showing the AI indicator.

Rendering reasoning and tool calls

If your agent stores its reasoning and tool calls on the reply, they arrive as custom attachments of the message. Decode them into parts and show them above the text in the same item:

List<AIMessagePart> aiParts(Message message) => AIMessagePart.parseAll([
      for (final attachment in message.attachments)
        if (attachment.rawType case final type?)
          (type: type, payload: {'id': attachment.id, ...attachment.extraData}),
    ]);

Use rawType, since type can be rewritten, for example into a link preview. Spread extraData last, so the step's own id wins over Attachment.id: from the network the step's id is in extraData, while the offline database stores it as Attachment.id. Your backend must set an id on every step, or each update gets a new random Attachment.id and the step starts over.

final parts = aiParts(message);

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    if (parts.isNotEmpty) AIMessagePartsView(parts: parts, approver: approver),
    StreamingMessageView(
      text: message.text ?? '',
      expectMoreText: message.extraData['generating'] == true,
    ),
  ],
);

approver is an AIToolApprover you create once, for example in your screen's State. It sends the user's answers to tool calls that wait for their approval, and with an AIToolAuthorizer it also offers the sign-ins that tool calls wait for. Leave it out if your agent doesn't ask. Pass the same parts to AIClientToolRunner.handle on every update of the message if your agent runs tools on the user's device.

Showing the AI indicator

The agent reports what it is doing with ai_indicator.* events. Listen for them on the channel, and keep the latest AITypingState in a ValueNotifier:

class TypingStateHandler extends ValueNotifier<AITypingState> {
  TypingStateHandler({required Channel channel}) : super(AITypingState.idle) {
    _subscription = channel.on().listen(_onEvent);
  }

  late final StreamSubscription<Event> _subscription;

  void _onEvent(Event event) {
    final state = switch (event.type) {
      // On failure the backend sends `ai_indicator.update` with
      // `AI_STATE_ERROR`, not a clear or stop, so `error` also ends a reply.
      EventType.aiIndicatorUpdate => event.aiState,
      EventType.aiIndicatorClear || EventType.aiIndicatorStop => AITypingState.idle,
      _ => null,
    };
    if (state != null) value = state;
  }

  @override
  void dispose() {
    unawaited(_subscription.cancel());
    super.dispose();
  }
}

For a reference implementation, check our sample app.

Handle ai_indicator.clear (and ai_indicator.stop) as well as the update: clear is what ends the indicator. Listening only to the update leaves the indicator on screen forever. channel.on() only delivers that channel's events, so an indicator from another conversation can't leak in. Create one handler per channel, and dispose of it with the screen.

Then show an AITypingIndicatorView below the message list while the agent is working:

Column(
  children: [
    Expanded(
      child: StreamMessageListView(messageBuilder: buildMessage),
    ),
    ValueListenableBuilder(
      valueListenable: typingStateHandler,
      builder: (context, state, _) {
        final text = switch (state) {
          AITypingState.thinking => 'Thinking',
          AITypingState.checkingSources => 'Checking sources',
          AITypingState.generating => 'Generating response',
          AITypingState.error => 'Something went wrong',
          _ => null,
        };
        if (text == null) return const SizedBox.shrink();
        return Padding(
          padding: const EdgeInsets.all(8),
          child: AITypingIndicatorView(text: text),
        );
      },
    ),
  ],
);

Once the state becomes generating, the text is streaming into the message itself. AITypingState.error is a terminal state too: the backend has already written the error into the message, and sends no ai_indicator.clear after it.

Stopping generation

Wire ChatComposer to the channel. Set controller.isGenerating while the assistant is replying, so the send button turns into a stop button, and call channel.stopAIResponse() when it's tapped:

final controller = ChatComposerController();

ChatComposer(
  controller: controller,
  onSendPressed: (text, selectedOption, attachments) =>
      channel.sendMessage(Message(text: text)),
  onStopPressed: () {
    controller.isGenerating = false;
    channel.stopAIResponse();
  },
);

The stop button turns back into the send button as soon as it's tapped, without waiting for the backend. The snippet ignores the attachments the composer hands over. See Show a message while it's sent for uploading the picked photos with the message.

Drive isGenerating from the same events as the indicator:

typingStateHandler.addListener(() {
  controller.isGenerating = switch (typingStateHandler.value) {
    AITypingState.thinking || AITypingState.checkingSources || AITypingState.generating => true,
    _ => false,
  };
});

This turns the stop button back into the send button as soon as ai_indicator.clear arrives, while the typewriter may still be revealing the reply. To keep the stop button until the reply is on screen, set isGenerating to false from the reply's StreamingMessageView.onFinished instead, and on AITypingState.error.

See Stop the reply for what the backend does with the stop event.

Building a standalone AI screen

For a ChatGPT-style app, show an AIConversation with AIConversationView and the composer. The list has the reply's steps, attachments and actions built in:

class AiChatScreen extends StatefulWidget {
  const AiChatScreen({super.key, required this.channel});

  // A channel you've watched, with `channel.watch()`.
  final Channel channel;

  @override
  State<AiChatScreen> createState() => _AiChatScreenState();
}

class _AiChatScreenState extends State<AiChatScreen> {
  late final _conversation = AIConversation<StreamAIMessage>(transport: ChannelTransport(widget.channel));

  @override
  void dispose() {
    _conversation.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Column(
    children: [
      Expanded(child: AIConversationView.conversation(conversation: _conversation)),
      ChatComposer.conversation(conversation: _conversation),
    ],
  );
}

StreamAIMessage is in Messages from Stream Chat, and ChannelTransport in A transport over a Stream Chat channel. The transport sends what the user asks to the channel, and stops the reply with channel.stopAIResponse(). The conversation shows the stop button from the moment the user sends, while the agent thinks and while it writes.

The transport's send gets the picked files as request.attachments, and uploads them with the message. The sample app shows a whole app.

Replacing components

The AI components and the Stream Chat widgets read their builders from the same StreamComponentFactory. A nested factory replaces its parent's builders instead of adding to them, and StreamChat adds a factory of its own when you pass it componentBuilders. So pass the AI builders in that same list, next to the Stream Chat ones:

StreamChat(
  client: client,
  componentBuilders: StreamComponentBuilders(
    extensions: [
      ...streamChatComponentBuilders(),
      ...streamAIComponentBuilders(codeBlock: codeBlock),
    ],
  ),
  child: child,
);

Replacing components in the quickstart lists every AI builder.

What the backend has to send

The app only renders what the agent writes, and it cannot tell when a field is missing. What your backend sends covers each one:

  • ai_generated: true on the reply when it is created, or it renders as an ordinary message with raw markdown.
  • One message, updated as tokens arrive, not one message per chunk. Use ephemeralUpdateMessage for the interim updates and a regular partial update for the final one, as described in Stream with ephemeral updates.
  • generating: true while streaming and false on the final update, on stop and on error.
  • ai_indicator.update while working and ai_indicator.clear when done, or there is no indicator.

For a reference implementation showing how to integrate these components with Stream Chat's client API, please check our sample app.