Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def get_sidebar_items_recipes():
recipes.others.chips,
recipes.others.speed_dial,
recipes.others.dark_mode_toggle,
recipes.others.chat,
],
),
]
Expand Down
203 changes: 203 additions & 0 deletions docs/recipes/others/chat.md
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.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

## 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
```
Comment thread
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
```
Comment thread
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.
Loading