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.0.1
  stream_chat_flutter_ai: ^0.1.0

The AI package needs Dart 3.11 or later and Flutter 3.41 or later. To let users attach photos or dictate messages, also add the platform permissions described on the overview page.

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,
        AnimatedDots,
        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(message: message);
    }
    return DefaultStreamMessageItem(props: defaultProps);
  },
);

AIMessageItem renders the message text without a bubble or avatar. Every time the backend updates the message, StreamingMessageView receives the longer text and animates the new part in:

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

  final Message message;
  final ValueChanged<TypewriterState>? onTypewriterStateChanged;

  @override
  Widget build(BuildContext context) {
    // Use the same text style as the regular message bubbles.
    final bodyStyle = context.streamTextTheme.bodyDefault.copyWith(
      color: context.streamColorScheme.textPrimary,
    );

    return Padding(
      padding: const EdgeInsets.all(16),
      child: StreamingMessageView(
        text: message.text ?? '',
        onTypewriterStateChanged: onTypewriterStateChanged,
        styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(p: bodyStyle),
        codeHighlighter: highlightCode,
        onTapLink: (text, href, title) => openLink(href),
      ),
    );
  }
}

highlightCode and openLink are helpers you define yourself: highlightCode is the syntax highlighter described on the overview page, 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. See Mark assistant messages with ai_generated for how the backend sets the flag.

Showing the AI indicator

The backend reports what the LLM 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();
  }
}

Create one handler per channel, and dispose of it with the screen. Then show an AITypingIndicatorView below the message list while the state is thinking or checkingSources:

Column(
  children: [
    Expanded(
      child: StreamMessageListView(messageBuilder: buildMessage),
    ),
    ValueListenableBuilder(
      valueListenable: typingStateHandler,
      builder: (context, state, _) {
        final text = switch (state) {
          AITypingState.thinking => 'Thinking',
          AITypingState.checkingSources => 'Checking sources',
          _ => 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. The sample app keeps the indicator visible with a "Generating" caption for that state, which the snippet above leaves out.

The typewriter can still be revealing text after the backend has finished. To keep the "Generating" caption visible until the animation is done, forward onTypewriterStateChanged from StreamingMessageView and treat TypewriterState.typing as still generating. The callback is invoked while the message list builds, so schedule setState for after the frame with WidgetsBinding.instance.addPostFrameCallback. The sample app's AITypingIndicatorStateView combines both signals into one caption, and a complete version is in conversation_view.dart. AITypingState.error is a terminal state too. You can optionally show an error caption for it, and the backend has already written the error into the message.

Sending messages and stopping a response

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: () => channel.stopAIResponse(),
);

Drive isGenerating from the same events as the indicator:

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

See Let users stop the response for what the backend does with the stop event.

Conversation starters

On a new chat screen, dock an AISuggestionsView above the composer and send the tapped suggestion as a message:

AISuggestionsView(
  suggestions: const [
    'What are the docs for the AI SDK?',
    'Summarize my last conversation',
  ],
  onSuggestionSelected: (text) => channel.sendMessage(Message(text: text)),
);

You can find a complete, working integration in the Flutter AI sample app.

Add Chat to my app: getstream.io/SKILL.md

The fastest way to build with Stream. Start a new project or improve an existing one. Full CLI and documentation integration out of the box.


Ask your agent:

/stream Build me a Social App with Feeds and Moderation.
/stream Any livestream calls running?
/stream Chat Flutter v10: <Your Question>