reflex-assistant-ui
assistant-ui chat components for Reflex, driven entirely from Python state.
The conversation lives in an rx.State: messages are plain dicts, streaming is
an async generator that yields tokens, and every user action — send, stop,
edit, regenerate, switch branch, pick a conversation — arrives as a normal
Reflex event handler. No API routes, no client-side model calls, no Tailwind.
pip install reflex-assistant-ui
Hello world
import reflex as rx
from reflex_assistant_ui import AssistantUIState, assistant_ui
class ChatState(AssistantUIState, rx.State):
async def _respond(self, history):
for word in "Hello from Python, one token at a time.".split():
yield word + " "
def index() -> rx.Component:
return assistant_ui.chat(
messages=ChatState.messages,
head_id=ChatState.head_id,
is_running=ChatState.is_running,
on_new=ChatState.handle_new,
on_edit=ChatState.handle_edit,
on_reload=ChatState.handle_reload,
on_cancel=ChatState.handle_cancel,
on_branch_change=ChatState.handle_branch_change,
height="100vh",
)
app = rx.App()
app.add_page(index)
AssistantUIState is a mixin, so it must be combined with rx.State:
class ChatState(AssistantUIState, rx.State). Inheriting from it alone gives a
class with no state vars.
What you get
| Streaming | yield a token, the thread updates; the stop button cancels mid-run |
| Markdown | GFM, tables, links, and fenced code with syntax highlighting and a copy button |
| Reasoning | yield {"reasoning": "…"} renders a block that expands while thinking and folds when done |
| Tool calls | yield {"tool_call": …} renders arguments and result in a collapsible card |
| Branching | Editing a message or regenerating a reply creates a sibling; the branch picker walks between them |
| Conversations | A sidebar thread list with create, switch, rename and delete |
| Attachments | Images and text files, via the composer button or drag-and-drop |
| Feedback | Thumbs up/down reported back to Python |
| Floating mode | The same thread inside a launcher popover |
| Theming | Self-contained CSS with light and dark palettes, restyled through CSS variables |
Components
from reflex_assistant_ui import assistant_ui
| Call | Renders |
|---|---|
assistant_ui.chat(**props) |
Provider + thread. The one-call shortcut. |
assistant_ui.floating_chat(**props) |
Provider + launcher button with the thread in a popover. |
assistant_ui.provider(*children, **props) |
The runtime. Every other component must be inside one. |
assistant_ui.thread(**props) |
The chat surface on its own. |
assistant_ui.thread_list(**props) |
The conversation sidebar. |
assistant_ui.modal(*children, **props) |
The launcher popover on its own. |
chat() and floating_chat() route keyword arguments by name: runtime props go
to the provider, everything else (including styling props such as height) goes
to the thread. For a custom layout, compose them yourself:
assistant_ui.provider(
rx.hstack(
rx.box(assistant_ui.thread_list(), width="17rem"),
rx.box(assistant_ui.thread(height="100%"), flex="1"),
height="100%",
),
messages=ChatState.messages,
on_new=ChatState.handle_new,
...,
)
Provider props
messages, head_id, is_running, is_disabled, is_send_disabled,
is_loading, suggestions, thread_list, attachments
("none" | "image" | "text" | "all"), enable_branching, enable_feedback.
Events: on_new, on_edit, on_reload, on_cancel, on_delete,
on_add_tool_result, on_feedback, on_branch_change, on_switch_to_thread,
on_switch_to_new_thread, on_rename_thread, on_archive_thread,
on_unarchive_thread, on_delete_thread.
Thread props
welcome_title, welcome_subtitle, welcome_suggestions, welcome_icon,
placeholder, auto_focus, show_welcome, show_action_bar,
show_branch_picker, show_feedback, show_avatar, show_attachments,
show_follow_up_suggestions, submit_mode ("enter" | "ctrlEnter" | "none"),
max_width.
Writing _respond
_respond receives the visible branch as [{"role": ..., "content": ...}],
oldest first, with system_prompt prepended when set. Yield any of:
yield "a text delta" # or {"text": "…"}
yield {"reasoning": "a thinking delta"}
yield {"tool_call": tool_call_part("search", {"q": "reflex"})}
yield {"part": image_part("https://example.com/chart.png")}
Re-yield a tool_call with the same tool_call_id to fill in its result:
from reflex_assistant_ui import tool_call_part
part = tool_call_part("get_weather", {"city": "Caracas"})
yield {"tool_call": part}
yield {"tool_call": {**part, "result": {"temp_c": 29}}}
Cancellation is cooperative: the consumer stops as soon as the user presses
stop, and self._cancel_requested lets a long generator bail out early.
stream_interval (default 0.05) is the minimum gap between websocket pushes.
Raise it for very long replies, set it to 0 to push on every token.
Talking to a model
The component does not care how the reply is produced — _respond just yields.
The demo uses LangChain, which keeps tools as ordinary Python functions:
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
@tool
def calculate(expression: str) -> dict:
"""Evaluate an arithmetic expression such as '(18 * 7) / 3'."""
...
class ChatState(AssistantUIState, rx.State):
async def _respond(self, history):
llm = ChatOllama(model="llama3.2", reasoning=True).bind_tools([calculate])
messages = to_langchain_messages(history)
gathered = None
async for chunk in llm.astream(messages):
gathered = chunk if gathered is None else gathered + chunk
if reasoning := chunk.additional_kwargs.get("reasoning_content"):
yield {"reasoning": reasoning}
if chunk.content:
yield chunk.content
for call in gathered.tool_calls:
part = tool_call_part(call["name"], call["args"], tool_call_id=call["id"])
yield {"tool_call": part}
result = await calculate.ainvoke(call["args"])
yield {"tool_call": {**part, "result": result}}
# feed the result back and stream the follow-up turn
assistant_ui_demo/assistant_ui_demo/backend.py has the complete loop, including
the retry that drops think: true for models without a reasoning mode.
Nothing ties you to LangChain: any client that can stream — an OpenAI SDK call,
httpx against your own endpoint, an agent framework — works the same way.
Listing local models
LangChain has no model-discovery API, so the package ships a tiny
dependency-free helper for it (it needs only httpx, which Reflex already
brings):
from reflex_assistant_ui import ollama
models = await ollama.list_models() # [] when the server is down
Messages
Messages are dicts, so they serialize into state without any custom types:
{
"id": "asst-3f2a…",
"parent_id": "user-91c0…",
"role": "assistant",
"content": [{"type": "text", "text": "Hello"}],
"status": {"type": "complete", "reason": "stop"},
}
parent_id is what makes branching work — a message with two children is a
fork, and the branch picker walks between them. The helpers keep the shape
consistent:
from reflex_assistant_ui import (
RUNNING, assistant_message, complete, file_part, image_part,
reasoning_part, source_part, text_part, tool_call_part, user_message,
)
status applies to assistant messages only: RUNNING while streaming, then
complete(), cancelled() or failed(error).
Styling
The package ships one stylesheet, scoped under .aui-root, with no Tailwind
dependency. Override the variables to restyle everything:
.aui-root {
--aui-accent: #7c3aed;
--aui-accent-fg: #ffffff;
--aui-radius-xl: 1rem;
--aui-user-bubble: #ede9fe;
--aui-thread-max-width: 52rem;
}
Dark mode follows Reflex's Radix .dark class, an explicit
[data-theme="dark"], or the OS preference.
Demo
With uv, from the repository root:
uv sync --extra demo # package (editable) + langchain-core, langchain-ollama
cd assistant_ui_demo
uv run --extra demo reflex run
uv run resolves the project from the root pyproject.toml, so the demo
extra has to be requested there too; uv pip install -e . alone does not bring
the LangChain dependencies and the demo fails with
ModuleNotFoundError: No module named 'langchain_core'.
With pip:
cd assistant_ui_demo
pip install -e ..
pip install -r requirements.txt # brings langchain-core and langchain-ollama
reflex run
Four pages:
/— streaming chat through LangChain'sChatOllama, with a model picker, temperature, tool calling, attachments and feedback. Falls back to a built-in echo bot when Ollama is not running, so the demo always starts./threads— the same chat plus a conversation sidebar./floating— the assistant as a popover over an ordinary page./showcase— a scripted conversation exercising every message part, with no model required.
For the Ollama pages:
ollama serve
ollama pull llama3.2 # or any model you already have
Tool calling needs a model trained for it (llama3.2, qwen2.5, mistral-nemo
and friends); with other models the demo simply answers without calling tools.
How it works
useExternalStoreRuntime is assistant-ui's adapter for applications that own
their own message state — which is exactly what a Reflex app does. The bridge
(assistant_ui_bridge.jsx, shipped with the package and imported by path)
converts Python's message dicts into ThreadMessageLike values, hands them to
the runtime as a branchable ExportedMessageRepository, and forwards every
runtime callback to a Reflex event. Nothing is inlined into the generated pages,
and the npm dependencies are pinned:
| package | version |
|---|---|
@assistant-ui/react |
0.15.19 |
@assistant-ui/react-markdown |
0.14.15 |
remark-gfm |
4.0.1 |
react-syntax-highlighter |
16.1.1 |
On the Python side the package itself depends only on reflex and httpx. The
demo's LangChain dependencies live in assistant_ui_demo/requirements.txt, not
in the package.
Every state delta rebuilds the message repository, which is O(n) in the number
of messages. That is unnoticeable for ordinary conversations; for very long ones
raise stream_interval so fewer deltas cross the wire.
Requirements
Python 3.10+, Reflex 0.9.11+.
Development
uv sync --extra dev --extra demo
uv run pytest
uvx ruff check . && uvx ruff format --check custom_components assistant_ui_demo tests
GitHub Actions run on every push and pull request to develop and main:
- Quality — ruff lint and format, pytest on Python 3.10–3.13, sdist/wheel
build with
twine check, and a production build of the demo app. - Security — gitleaks, bandit, semgrep, pip-audit, dependency review and CodeQL (Python and JavaScript), plus a weekly scheduled run.
Releasing
-
Bump the version in
pyproject.tomland incustom_components/reflex_assistant_ui/__init__.py, merge tomain. -
Tag the merge commit and push the tag:
git tag v0.1.0 && git push origin v0.1.0
The Release workflow re-runs quality and security checks, verifies that the
tag is on main and matches both versions, builds the distribution, publishes it
to PyPI through Trusted Publishing and creates the GitHub release with the files
attached.
License
Apache-2.0. assistant-ui is MIT-licensed by AgentbaseAI Inc.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file reflex_assistant_ui-0.1.0.tar.gz.
File metadata
- Download URL: reflex_assistant_ui-0.1.0.tar.gz
- Upload date:
- Size: 41.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
778b4d83a60ff012d0bae7a81b1f75110b5d5c65c7ffd0b63be7bd9440bcad45
|
|
| MD5 |
e8de08d04176722e980d34ccdfe8e15f
|
|
| BLAKE2b-256 |
03971d793069a5e86abf7558ccecbc3b7808b300d1cd442404b96acf98419616
|
Provenance
The following attestation bundles were made for reflex_assistant_ui-0.1.0.tar.gz:
Publisher:
release.yml on ecrespo/reflex-assistant-ui
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
reflex_assistant_ui-0.1.0.tar.gz -
Subject digest:
778b4d83a60ff012d0bae7a81b1f75110b5d5c65c7ffd0b63be7bd9440bcad45 - Sigstore transparency entry: 2837631996
- Sigstore integration time:
-
Permalink:
ecrespo/reflex-assistant-ui@19f95405ab01b9021de03396c250152951ce3553 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ecrespo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@19f95405ab01b9021de03396c250152951ce3553 -
Trigger Event:
push
-
Statement type:
File details
Details for the file reflex_assistant_ui-0.1.0-py3-none-any.whl.
File metadata
- Download URL: reflex_assistant_ui-0.1.0-py3-none-any.whl
- Upload date:
- Size: 39.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dd5ac17787a01f32b2482ec76a26b4a77a93ec87a6b331067cd2c0a679c93578
|
|
| MD5 |
48b2e63303702d5c63571ed581310900
|
|
| BLAKE2b-256 |
4de49eb93b049ee2ac36b125c39b5bee525095d4831680d93af3b7a74dae23b5
|
Provenance
The following attestation bundles were made for reflex_assistant_ui-0.1.0-py3-none-any.whl:
Publisher:
release.yml on ecrespo/reflex-assistant-ui
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
reflex_assistant_ui-0.1.0-py3-none-any.whl -
Subject digest:
dd5ac17787a01f32b2482ec76a26b4a77a93ec87a6b331067cd2c0a679c93578 - Sigstore transparency entry: 2837632043
- Sigstore integration time:
-
Permalink:
ecrespo/reflex-assistant-ui@19f95405ab01b9021de03396c250152951ce3553 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ecrespo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@19f95405ab01b9021de03396c250152951ce3553 -
Trigger Event:
push
-
Statement type: