Skip to main content

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's ChatOllama, 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

  1. Bump the version in pyproject.toml and in custom_components/reflex_assistant_ui/__init__.py, merge to main.

  2. 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

reflex_assistant_ui-0.1.0.tar.gz (41.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

reflex_assistant_ui-0.1.0-py3-none-any.whl (39.3 kB view details)

Uploaded Python 3

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

Hashes for reflex_assistant_ui-0.1.0.tar.gz
Algorithm Hash digest
SHA256 778b4d83a60ff012d0bae7a81b1f75110b5d5c65c7ffd0b63be7bd9440bcad45
MD5 e8de08d04176722e980d34ccdfe8e15f
BLAKE2b-256 03971d793069a5e86abf7558ccecbc3b7808b300d1cd442404b96acf98419616

See more details on using hashes here.

Provenance

The following attestation bundles were made for reflex_assistant_ui-0.1.0.tar.gz:

Publisher: release.yml on ecrespo/reflex-assistant-ui

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file reflex_assistant_ui-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for reflex_assistant_ui-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dd5ac17787a01f32b2482ec76a26b4a77a93ec87a6b331067cd2c0a679c93578
MD5 48b2e63303702d5c63571ed581310900
BLAKE2b-256 4de49eb93b049ee2ac36b125c39b5bee525095d4831680d93af3b7a74dae23b5

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page