-
Notifications
You must be signed in to change notification settings - Fork 1.7k
docs: chat app recipe with streaming patterns #6788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
amsraman
wants to merge
6
commits into
main
Choose a base branch
from
docs/chat-app-recipe
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+204
−0
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d502a7e
docs: chat app recipe with streaming patterns
amsraman d1dfd4b
docs: ruff-format the chat recipe code blocks
amsraman 32622aa
docs: fix chat recipe streaming (yield outside state lock, import ope…
amsraman 98976d6
docs: register chat recipe in the Other recipes sidebar section
amsraman 7220075
docs: apply ruff-format to chat recipe streaming snippets
amsraman a131a48
Keep streamed tokens tied to their own assistant message
amsraman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| ```python exec | ||
| import reflex as rx | ||
| ``` | ||
|
|
||
| # Chat App | ||
|
|
||
| A chat interface renders a running list of messages and lets the user send new ones. Each | ||
| message is a `{"role": ..., "content": ...}` dictionary held in state, and the component | ||
| loops over that list to draw bubbles. This recipe covers a basic chat UI and the | ||
| streaming patterns that keep the loading experience clean. | ||
|
|
||
| ## Basic Chat UI | ||
|
|
||
| Store messages in state, append the user's message on submit, and produce a reply. The | ||
| example below echoes the input so it runs on its own; in a real app you would call your | ||
| model provider instead. | ||
|
|
||
| ```python demo exec | ||
| class ChatUIState(rx.State): | ||
| messages: list[dict[str, str]] = [] | ||
|
|
||
| @rx.event | ||
| def send_message(self, form_data: dict): | ||
| text = form_data.get("message", "").strip() | ||
| if not text: | ||
| return | ||
| self.messages.append({"role": "user", "content": text}) | ||
| self.messages.append({"role": "assistant", "content": f"You said: {text}"}) | ||
|
|
||
|
|
||
| def message_bubble(message: dict[str, str]) -> rx.Component: | ||
| is_user = message["role"] == "user" | ||
| return rx.el.div( | ||
| rx.el.div( | ||
| message["content"], | ||
| class_name=rx.cond( | ||
| is_user, | ||
| "bg-blue-500 text-white rounded-lg px-4 py-2 max-w-md", | ||
| "bg-gray-100 text-gray-900 rounded-lg px-4 py-2 max-w-md", | ||
| ), | ||
| ), | ||
| class_name=rx.cond(is_user, "flex justify-end", "flex justify-start"), | ||
| ) | ||
|
|
||
|
|
||
| def chat_ui() -> rx.Component: | ||
| return rx.el.div( | ||
| rx.el.div( | ||
| rx.foreach(ChatUIState.messages, message_bubble), | ||
| class_name="flex flex-col gap-3 p-4 h-96 overflow-y-auto", | ||
| ), | ||
| rx.el.form( | ||
| rx.el.input( | ||
| name="message", | ||
| placeholder="Type a message...", | ||
| class_name="flex-1 border rounded-lg px-3 py-2", | ||
| ), | ||
| rx.el.button( | ||
| "Send", | ||
| type="submit", | ||
| class_name="px-4 py-2 bg-blue-500 text-white rounded-lg", | ||
| ), | ||
| on_submit=ChatUIState.send_message, | ||
| reset_on_submit=True, | ||
| class_name="flex gap-2 p-4 border-t", | ||
| ), | ||
| class_name="max-w-xl mx-auto border rounded-xl", | ||
| ) | ||
| ``` | ||
|
|
||
| ## Streaming Responses | ||
|
|
||
| When a model streams its reply token by token, show a loading indicator immediately and | ||
| append the assistant message only once the stream actually starts producing content. | ||
|
|
||
| The key rule: **do not pre-append an empty assistant message** before the response | ||
| begins. Doing so renders a blank bubble alongside the loading dots, producing a broken | ||
| double-indicator experience. | ||
|
|
||
| Both snippets below use the [`openai`](https://pypi.org/project/openai/) package and read | ||
| the API key from the `OPENAI_API_KEY` environment variable. They also run as | ||
| [background events](/docs/events/background-events/), so all state mutations happen inside | ||
| `async with self:` and every `yield` is placed **after** the block exits — holding the | ||
| state lock across a `yield` would block every other event handler until the stream | ||
| finishes. | ||
|
|
||
| ### Incorrect | ||
|
|
||
| An empty assistant bubble appears next to the loading dots because it is appended before | ||
| any content arrives: | ||
|
|
||
| ```python | ||
| import openai | ||
|
|
||
|
|
||
| class ChatState(rx.State): | ||
| messages: list[dict[str, str]] = [] | ||
| is_streaming: bool = False | ||
|
|
||
| @rx.event(background=True) | ||
| async def send_message(self, form_data: dict): | ||
| user_msg = form_data.get("message", "").strip() | ||
| if not user_msg: | ||
| return | ||
|
|
||
| async with self: | ||
| self.messages.append({"role": "user", "content": user_msg}) | ||
| # BAD: appending an empty assistant message creates a blank bubble | ||
| self.messages.append({"role": "assistant", "content": ""}) | ||
| self.is_streaming = True | ||
| request_messages = [ | ||
| {"role": m["role"], "content": m["content"]} for m in self.messages[:-1] | ||
| ] | ||
|
|
||
| client = openai.AsyncOpenAI() | ||
| stream = await client.chat.completions.create( | ||
| model="gpt-4o", | ||
| messages=request_messages, | ||
| stream=True, | ||
| ) | ||
| async for chunk in stream: | ||
| delta = chunk.choices[0].delta.content or "" | ||
| async with self: | ||
| self.messages[-1]["content"] += delta | ||
|
|
||
| async with self: | ||
| self.is_streaming = False | ||
| ``` | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| ### Correct | ||
|
|
||
| The loading dots show first, and the assistant bubble appears only on the first streamed | ||
| token. Two more details make this safe: the stream is wrapped in a `try/finally` so | ||
| `is_streaming` always resets even if the API call raises, and — because background events | ||
| run concurrently, so a second submit can start before the first reply finishes — an | ||
| `is_generating` guard serializes requests while the assistant message is updated by a | ||
| captured index rather than `self.messages[-1]`, which would otherwise point at whichever | ||
| message is currently last and let overlapping streams write to the wrong bubble: | ||
|
|
||
| ```python | ||
| import openai | ||
|
|
||
|
|
||
| class ChatState(rx.State): | ||
| messages: list[dict[str, str]] = [] | ||
| is_streaming: bool = False | ||
| is_generating: bool = False | ||
|
|
||
| @rx.event(background=True) | ||
| async def send_message(self, form_data: dict): | ||
| user_msg = form_data.get("message", "").strip() | ||
| if not user_msg: | ||
| return | ||
|
|
||
| async with self: | ||
| if self.is_generating: | ||
| return # a reply is still streaming; ignore overlapping submits | ||
| self.is_generating = True | ||
| self.messages.append({"role": "user", "content": user_msg}) | ||
| self.is_streaming = True | ||
| request_messages = [ | ||
| {"role": m["role"], "content": m["content"]} for m in self.messages | ||
| ] | ||
| yield # flush outside the lock so the loading indicator shows now | ||
|
|
||
| assistant_index = None | ||
| try: | ||
| client = openai.AsyncOpenAI() | ||
| stream = await client.chat.completions.create( | ||
| model="gpt-4o", | ||
| messages=request_messages, | ||
| stream=True, | ||
| ) | ||
| async for chunk in stream: | ||
| delta = chunk.choices[0].delta.content or "" | ||
| if not delta: | ||
| continue | ||
| async with self: | ||
| if assistant_index is None: | ||
| # First token: create the bubble and capture its index | ||
| self.messages.append({"role": "assistant", "content": delta}) | ||
| assistant_index = len(self.messages) - 1 | ||
| self.is_streaming = False # hide dots on first token | ||
| else: | ||
| self.messages[assistant_index]["content"] += delta | ||
| finally: | ||
| async with self: | ||
| self.is_streaming = False | ||
| self.is_generating = False | ||
| ``` | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| The differences that matter: | ||
|
|
||
| 1. Set `is_streaming = True`, then `yield` **after** leaving `async with self:` to flush | ||
| the loading indicator without holding the state lock. | ||
| 2. Build the API request from `self.messages` directly — there is no trailing empty | ||
| message to slice off with `[:-1]`. | ||
| 3. Append the assistant message only on the first token from the stream. | ||
| 4. Hide the loading indicator on the first token, not at the end of the stream. | ||
| 5. Wrap the stream in `try/finally` so `is_streaming` resets even when the API call fails. | ||
| 6. Guard overlapping submits with `is_generating` and update the assistant message by its | ||
| captured index (not `self.messages[-1]`) — background events run concurrently, so a | ||
| second stream could otherwise append tokens to the wrong message. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.