SDK integration
The main stream-chat-react entry point has three parts for AI replies: StreamedMessageText types out a reply as it streams, AIStateIndicator shows what the LLM is doing, and the default composer's StopAIGenerationButton lets the user stop it. This page shows how to turn each on, and how to use the components from stream-chat-react/ai-components in their place. The AI integrations overview covers those components, and how the backend drives all of this.
For example, the user asks the support bot a question:
- The bot posts an empty reply, marked
ai_generated, and sendsai_indicator.updatewithAI_STATE_THINKING. - The client sets the channel's AI state to thinking.
AIStateIndicatorshows "Thinking...", and the composer's send button becomes the stop button. - The bot writes the answer into the reply, a few words at a time.
StreamedMessageTexttypes each new part out. - The bot stores the final text and sends
ai_indicator.clear. The indicator goes away, and the send button comes back.
Best Practices
- Mark AI replies on the backend, and tell
Chathow to recognise them withisMessageAIGenerated, so every message list renders them the same way. - Show an indicator only while the LLM is busy: thinking, checking sources or generating.
- Keep a way to stop a long reply. The default composer has one; a composer of your own needs its own button.
- Keep the default message layout around streamed text unless you need a message component of your own.
- Validate attachments and model choices on the backend, not only in the composer.
StreamedMessageText
MessageUI renders StreamedMessageText in place of MessageText for every message that isMessageAIGenerated returns true for. It shows the text the message has when it mounts, then types out whatever the text grows by. A reply already stored doesn't replay the animation when the user scrolls back to it.
Pass isMessageAIGenerated to Chat. AI replies carry the custom field ai_generated, which the client puts under message.custom:
import type { PropsWithChildren } from "react";
import type { LocalMessage } from "stream-chat";
import { Chat } from "stream-chat-react";
// Declared outside the component, so Chat gets the same function on every render.
const isMessageAIGenerated = (message: LocalMessage) =>
!!message.custom?.ai_generated;
export const App = ({ children }: PropsWithChildren) => (
<Chat client={client} isMessageAIGenerated={isMessageAIGenerated}>
{children}
</Chat>
);For TypeScript, declare the field, optional, in CustomMessageData:
import "stream-chat";
declare module "stream-chat" {
interface CustomMessageData {
ai_generated?: boolean;
generating?: boolean;
}
}Mark assistant messages with ai_generated explains both fields and how the backend sets them. MessageUI also hides the "Edited" label on these messages, since every streamed update edits them.
When an ai_indicator.stop event reaches the channel, StreamedMessageText shows the rest of the text at once instead of typing it out.
Using StreamingMessage instead
Replace StreamedMessageText through WithComponents. This one renders the text with StreamingMessage from stream-chat-react/ai-components, which adds markdown, code highlighting, tables and charts:
import type { PropsWithChildren } from "react";
import { useMessageContext, WithComponents } from "stream-chat-react";
import { StreamingMessage } from "stream-chat-react/ai-components";
const MarkdownStreamedText = () => {
const { message } = useMessageContext();
return <StreamingMessage text={message.text ?? ""} />;
};
const overrides = { StreamedMessageText: MarkdownStreamedText };
export const WithMarkdownReplies = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);Load stream-chat-react/dist/css/ai-components.css next to the SDK's stylesheet, as Usage shows. StreamingMessage doesn't listen for ai_indicator.stop: call skipAnimation() on its ref when the event arrives, if it should show the rest at once.
A message component of your own
The override above still renders inside MessageUI, with its avatar, reactions and status. To render AI replies differently, replace MessageUI, and keep the default for every other message:
import type { PropsWithChildren } from "react";
import {
Attachment,
MessageUI,
useMessageContext,
WithComponents,
} from "stream-chat-react";
import { StreamingMessage } from "stream-chat-react/ai-components";
const AIAwareMessage = () => {
const { handleAction, isMessageAIGenerated, message } = useMessageContext();
if (!isMessageAIGenerated?.(message)) return <MessageUI />;
const attachments = message.attachments ?? [];
return (
<div className="str-chat__message str-chat__message--other ai-reply">
<div className="str-chat__message-inner">
<div className="str-chat__message-bubble">
{attachments.length > 0 && (
<Attachment
actionHandler={handleAction}
attachments={attachments}
/>
)}
<StreamingMessage text={message.text ?? ""} />
</div>
</div>
</div>
);
};
const overrides = { MessageUI: AIAwareMessage };
export const WithAIMessages = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);A custom MessageUI receives no props: it reads the message from useMessageContext(). Overriding components explains the override keys.
AIStateIndicator
AIStateIndicator shows "Thinking..." while the channel's AI state is AI_STATE_THINKING, and "Generating..." while it's AI_STATE_GENERATING. In every other state it renders nothing.
No SDK component renders it, so render it yourself, inside Channel. Between the message list and the composer, it sits just above where the user types:
import type { Channel as StreamChannel } from "stream-chat";
import {
AIStateIndicator,
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react";
export const AssistantChannel = ({ channel }: { channel: StreamChannel }) => (
<Channel channel={channel}>
<MessageList />
<AIStateIndicator />
<MessageComposer />
</Channel>
);With ChatView, put it in the same place among the children of ChannelSlot:
import {
AIStateIndicator,
ChannelHeader,
MessageComposer,
MessageList,
} from "stream-chat-react";
import { ChannelSlot } from "stream-chat-react/slot-layout";
export const AssistantChannelSlot = () => (
<ChannelSlot slot="channel">
<ChannelHeader />
<MessageList />
<AIStateIndicator />
<MessageComposer />
</ChannelSlot>
);
It reads the channel from context. Its channel prop shows another channel's state instead, but it still has to render inside a Channel or a Thread. To show other states, such as AI_STATE_EXTERNAL_SOURCES, read the state with useAIState(channel), as Drive the AI indicator from events shows.
StopAIGenerationButton
While the channel's AI state is AI_STATE_THINKING or AI_STATE_GENERATING, the composer shows StopAIGenerationButton in place of the send button. Clicking it calls Channel.stopAIResponse(), which sends ai_indicator.stop to the channel. The backend stops the reply when it gets the event.
The default button isn't exported. Replace it through WithComponents; your component receives the onClick that stops the reply:
import type { ComponentProps, PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react";
// Receives the props of a button, including the onClick that stops the reply.
const StopButton = ({ onClick }: ComponentProps<"button">) => (
<button className="stop-reply-button" onClick={onClick} type="button">
Stop
</button>
);
const overrides = { StopAIGenerationButton: StopButton };
export const WithStopButton = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);Set StopAIGenerationButton to null in the overrides to never show a stop button. The send button then stays while the LLM replies.
Let users stop the response lists when the default button doesn't show, and what the backend does with the event.
With AIMessageComposer
To use AIMessageComposer as the composer, render it from a MessageComposerUI of your own. It writes into the channel's or the thread's message composer, so sending and uploads work as with the default composer. The text reaches the message composer only on submit, so a draft doesn't keep it. AIMessageComposer has no stop button, so this one adds it:
import type { ChangeEvent, FormEvent } from "react";
import { AIStates } from "stream-chat";
import {
MessageComposer,
useAIState,
useAttachmentsForPreview,
useChannel,
useMessageComposerController,
WithComponents,
} from "stream-chat-react";
import { AIMessageComposer } from "stream-chat-react/ai-components";
const AIComposerUI = () => {
const channel = useChannel();
const { aiState } = useAIState(channel);
const composer = useMessageComposerController();
const { attachments } = useAttachmentsForPreview();
const isReplying =
aiState === AIStates.Thinking || aiState === AIStates.Generating;
const onChange = (event: ChangeEvent<HTMLFormElement>) => {
const input = event.currentTarget.elements.namedItem("attachments");
if (input instanceof HTMLInputElement && input.files?.length) {
void composer.attachmentManager.uploadFiles(input.files);
}
};
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
const text = new FormData(form).get("message");
composer.textComposer.setText(typeof text === "string" ? text : "");
// Keeps what the user wrote when there was nothing to send.
const result = await composer.send();
if (result !== "nothing-to-send") form.reset();
};
return (
<AIMessageComposer onChange={onChange} onSubmit={onSubmit}>
<AIMessageComposer.AttachmentPreview>
{attachments.map((attachment) => {
const { file, id, previewUri, uploadState } =
attachment.localMetadata;
if (!(file instanceof File)) return null;
return (
<AIMessageComposer.AttachmentPreview.Item
file={file}
imagePreviewSource={
typeof previewUri === "string" ? previewUri : undefined
}
key={id}
onDelete={() =>
composer.attachmentManager.removeAttachments([id])
}
state={uploadState}
/>
);
})}
</AIMessageComposer.AttachmentPreview>
<AIMessageComposer.TextInput />
<AIMessageComposer.FileInput name="attachments" />
<AIMessageComposer.SpeechToTextButton />
{isReplying ? (
<button onClick={() => channel.stopAIResponse()} type="button">
Stop
</button>
) : (
<AIMessageComposer.SubmitButton />
)}
</AIMessageComposer>
);
};
const overrides = { MessageComposerUI: AIComposerUI };
// Render inside Channel or Thread, in place of MessageComposer.
export const AIComposer = () => (
<WithComponents overrides={overrides}>
<MessageComposer />
</WithComponents>
);How it fits together:
- Text.
AIMessageComposerkeeps the text in its own state. On submit, the component copies it into the message composer withTextComposer.setText(), then callsMessageComposer.send(). - Attachments. The file input's
onChangeuploads the files through the message composer'sattachmentManager, and the previews read them back withuseAttachmentsForPreview().FileInputhas no defaultname, and the handler looks the input up by name, so keepname="attachments". - The result.
MessageComposer.send()resolves'sent','failed'or'nothing-to-send'. After the first two, the message composer is already empty, so the component resets the form. After'nothing-to-send', nothing was sent, so it keeps what the user wrote. - Stopping. The stop button replaces the submit button while the LLM replies, as the default composer does.
Load stream-chat-react/dist/css/ai-components.css next to the SDK's stylesheet. See Usage.