kmail.at
contact

elsewhere

EnglishEN
← learning

gradio · difficulty ◆◆

gradio Chatbot — the transcript component, and the data format Gradio deleted

gr.Chatbot keeps no state of its own: it paints the list you return, and every interaction - like, retry, undo, edit - comes back carrying the index of the bubble that was touched.

Gradio accepts editable=True on a Chatbot, stores it in the config, and then renders not a single edit button. The parameter wants a string.

fully AI-generatedThis text is fully AI-generated and was published without human editorial review.

2026-10-17 · 5 min read

$ gr.Chatbot()
Gradio 3.0
Importable as gr.Chatbot

2022-05-16. 2.8.6 (2022-03-02) shipped only the label-only class in gradio/outputs.py, reachable via the 'chatbot' shortcut string.

38
Parameters in 6.18.0

inspect.signature(gr.Chatbot.__init__); 41 in 5.49.1, 22 in 3.50.2, 6 in 3.0. 6.0 removed type and bubble_full_width.

10
Events on the component

gr.Chatbot.EVENTS: change, select, like, retry, undo, example_select, option_select, clear, copy, edit. Three of them (change, select, like) existed by 4.0.0.

4.38.0 to 5.49.1
Life of the type= parameter

default 'tuples' when it appeared in 4.38.0 (2024-07-12), None (auto-detect) in 5.0.0, gone in 6.0.0.

What it does

gr.Chatbot is a scrollable transcript that takes a list - dicts with role and content, or ChatMessage objects - and paints user turns right, assistant turns left, with markdown, tables, LaTeX, code fences, images, audio and video in the bubbles. It holds no state: you keep the list, your handler returns it, the component renders it. The interesting half runs the other way. Its EVENTS list is ten long in 6.18.0 - change, select, like, retry, undo, example_select, option_select, clear, copy, edit - and each interaction hands back the message index: like returns the content of the row (or the whole group) plus liked: true/false, retry and undo return the last user message, edit returns the new text, the old text and the position. Register the matching handler and the affordance appears on the bubble: a thumb, a retry arrow, a pencil.

Why it matters

Every chat demo starts as a transcript and finishes as a transcript with buttons. ChatInterface gives you the loop but not the buttons; this is the layer underneath, and it is where like-this-answer, regenerate-that-one and fix-my-typo actually live. It is also the cheapest way to show a model's working: a message can carry metadata - title, log, duration - which renders as a small collapsible header above the answer. For a tool-using demo that reads as the agent showing its steps, for the price of one dict key. And streaming costs one extra keyword: write the handler as a generator and yield the same list.

Example

$ import gradio as gr

def answer(msg, history):
    history = history + [
        {"role": "user", "content": msg},
        {"role": "assistant", "content": f"You asked: {msg} - the release notes cover that under gr.Chatbot."},
    ]
    return history, ""          # the new transcript, then an empty box

with gr.Blocks(title="Docs helper") as demo:
    chat = gr.Chatbot(label="Assistant", height=340,
                      placeholder="Ask about the 6.18.0 release notes")
    msg = gr.Textbox(label="Message", placeholder="e.g. what changed in gr.Chatbot?")
    msg.submit(answer, [msg, chat], [chat, msg])

demo.launch()
* Running on local URL:  http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.

Rendered UI (gradio 6.18.0, one column, read back from window.gradio_config and the live DOM):
- A Chatbot labelled "Assistant", 340px tall, with a two-button toolbar in the top-right corner (aria-labels "Clear" and "Copy conversation").
- Empty on load, so the transcript area is div.placeholder-content[role=complementary] > div.placeholder > span.md holding "Ask about the 6.18.0 release notes".
- Below it the Textbox "Message", placeholder "e.g. what changed in gr.Chatbot?".
- Typing the question and pressing Enter leaves the box empty (value "") and paints two rows: div.message-row.user-row with the question, then div.message-row.bot-row with "You asked: what changed in gr.Chatbot? - the release notes cover that under gr.Chatbot." The placeholder is gone.
- The registered dependency, straight out of demo.config: {"targets": [[textbox, "submit"]], "inputs": [textbox, chatbot], "outputs": [chatbot, textbox], "queue": true, "api_name": "answer", "connection": "sse", "stream_every": 0.5}.

Verified by launching this file and driving it in headless Chromium: after Enter the textarea value was '' and the two bubbles read back exactly as above. The answer() body is a stand-in - swap it for your model call and nothing else changes.

$ import time
import gradio as gr

def streamy(msg, history):
    history = history + [{"role": "user", "content": msg},
                         {"role": "assistant", "content": ""}]   # empty bubble first
    yield history
    for token in ["Loading ", "the checkpoint ", "from disk", "..."]:
        time.sleep(0.4)
        history[-1]["content"] += token
        yield history                                            # repaint, not append

with gr.Blocks(title="Slow model, fast UI") as demo:
    chat = gr.Chatbot(label="Assistant", height=320)
    msg = gr.Textbox(label="Message")
    msg.submit(streamy, [msg, chat], chat)

demo.launch()
* Running on local URL:  http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.

Rendered UI + measured traffic (gradio 6.18.0, headless Chromium, sending "stream please"):
- innerText of the bot bubble, sampled every 400ms: "" -> "Loading" -> "Loading the checkpoint" -> "Loading the checkpoint from disk" -> "Loading the checkpoint from disk...".
- Exactly two HTTP calls for the whole turn: POST /gradio_api/queue/join? with body {"data":["stream please",[]],"fn_index":0,"trigger_id":2,"session_hash":"o47j35gnma"}, then one GET /gradio_api/queue/data?session_hash=o47j35gnma which stays open. Every later yield rides that one connection.
- The empty assistant message paints immediately (an empty span.md inside the bot bubble on the first frame), so the turn visibly starts before the first token arrives.
- The dependency is marked {"generator": true}, "connection": "sse", "stream_every": 0.5.

The assistant message starts as an empty string and is filled in place, which is why you append {'role':'assistant','content':''} first and then mutate history[-1]['content']. A generator plus one output component is the entire streaming contract.

$ import gradio as gr

HISTORY = [
    {"role": "user", "content": "how many rows in sales.csv?"},
    {"role": "assistant", "content": "412 rows, 12 columns.",
     "metadata": {"title": "pandas.read_csv", "log": "sales.csv", "duration": 0.42}},
]

with gr.Blocks(title="Tool-using assistant") as demo:
    chat = gr.Chatbot(value=HISTORY, label="Assistant", height=340,
                      feedback_options=("Like", "Dislike"))
    audit = gr.Textbox(label="feedback log")

    def on_like(ev: gr.LikeData):
        vote = "up" if ev.liked else "down"
        return f"{vote} on message {ev.index}: {ev.value}"

    chat.like(on_like, None, audit)

demo.launch()
* Running on local URL:  http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.

Rendered UI (gradio 6.18.0):
- Two rows on load. The user row has only a "Copy message" button in div.message-buttons-right.
- The assistant bubble opens with a collapsible thought header: div.thought-group > div.title.expanded[role=button] holding the metadata. Its innerText is "pandas.read_csv" then "sales.csv (0.4s)" - my duration of 0.42 is formatted to one decimal - and the answer "412 rows, 12 columns." sits under it as normal markdown.
- Because a like handler exists, the bot row gains div.message-buttons-left with "Copy message, Dislike, Like". Same demo without chat.like(): only "Copy message" is rendered.
- Clicking Like wrote "up on message 1: ['412 rows, 12 columns.']" into the audit Textbox: LikeData.index is the position in the transcript, ev.value is a LIST of the contents in the row (a list of one here, a whole merged group once consecutive messages are grouped), and liked is True/False.

feedback_options and feedback_value are yours to label; 'Like'/'Dislike' are just strings. like_user_message defaults to False, so user rows never grow thumbs. The metadata keys I could confirm rendering are title, log and duration.

$ import gradio as gr

def on_edit(history, ev: gr.EditData):
    history = history[:ev.index] + [            # drop the corrected message and everything after
        {"role": "user", "content": ev.value},
        {"role": "assistant", "content": f"re-ran the pipeline on '{ev.value}'"},
    ]
    return history

with gr.Blocks(title="Fix a typo, re-run the model") as demo:
    chat = gr.Chatbot(
        value=[{"role": "user", "content": "plot the sales column"},
               {"role": "assistant", "content": "done - 12 points, saved to chart.png"}],
        label="Assistant", height=300, editable="user",
        placeholder="Ask a question, then fix a typo with the pencil")
    chat.edit(on_edit, [chat], [chat])

demo.launch()
* Running on local URL:  http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.

Rendered UI (gradio 6.18.0), then driven in headless Chromium:
- editable="user" puts an "Edit" button next to "Copy message" on user rows. With editable=True instead, the config still says editable: true and not one Edit button is rendered - the parameter's annotation is Literal["user", "all"].
- Clicking Edit swaps the bubble body for <textarea class="edit-textarea">, prefilled with the message text and autofocused, with Submit and Cancel buttons beside it.
- Submit fired EditData(index=0, value="plot the revenue column instead", previous_value="plot the sales column"). The handler returned history[:0] plus the corrected pair, and the two rows then read "plot the revenue column instead" and "re-ran the pipeline on 'plot the revenue column instead'".
- Cancel closed the editor and fired nothing (the event log stayed empty); the row kept the text it had.
- The edit event is the one path that does not re-run your function by itself: the new text is handed to you and the transcript is whatever you return.

history arrives as an input in its pre-edit state, so slice it at ev.index and rebuild from there. Passing [chat] both in and out is what makes the corrected transcript the rendered one.

Common flags

value=
A list of messages: {'role': 'user'|'assistant'|'system', 'content': str} or gr.ChatMessage. A bare string, a (user, reply) tuple or a dict without role raises at postprocess time.
editable="user" | "all"
Puts the pencil on user rows, or on every row, and enables .edit(). editable=True is accepted, stored as true, and renders nothing - the annotation is Literal['user', 'all'].
feedback_options=("Like", "Dislike")
Labels for the two thumbs; feedback_value pre-selects one. With no .like() handler no thumbs are rendered at all.
placeholder=
Shown only while the transcript is empty. There is no built-in default: leave it out and the empty state renders an empty div with nothing in it.
group_consecutive_messages=True
Default: consecutive messages from the same role merge into one row separated by a blank line. False gives each message its own row.
buttons=["share", "copy", "copy_all"]
Block-level buttons in the transcript's top-right corner, or your own Button object. None renders all three; the per-message copy button is separate.
height=400, resizable=
height defaulted to None until 5.0.0 made it 400; resizable=True adds a drag handle and max_height/min_height then bound it.

History

A string before it was a class (2.8.6 to 3.0)

In gradio 2.8.6 (2022-03-02) the only Chatbot in the package sat in gradio/outputs.py, took a single argument (label), returned its input unchanged from postprocess, and was reachable as the shortcut string 'chatbot' in gr.Interface - gr.Chatbot was not exported as a name, and __init__.py in the wheel does not mention it. The real component arrived with Blocks in 3.0 (2022-05-16): six parameters (value, color_map, label, show_label, visible, elem_id) in the monolithic components.py, carrying the docstring 'Preprocessing: this component does *not* accept input.' 3.0 and 3.12.0 then shipped the old one-argument class alongside it in gradio/outputs.py, warning on import. By 3.50.2 it had its own module and 22 parameters.

Tuples: added, made optional, deleted (4.38 to 6.0)

For three years the transcript was a list of (user, response) tuples, which is why every older tutorial still writes them. 4.38.0 (2024-07-12) put a type parameter on the signature with default 'tuples'; 4.40.0 added the ChatMessage dataclass; 5.0.0 flipped type's default to None (infer it from the data), raised height from None to 400 and added retry, undo and example_select. 6.0.0 deleted type and bubble_full_width outright - 41 parameters became 38 - while buttons (replacing show_copy_button), reasoning_tags, allow_tags, layout, watermark and group_consecutive_messages came in. Parameter counts from the wheels: 6 (3.0), 22 (3.50.2), 28 (4.44.1), 41 (5.49.1), 38 (6.0.0 through 6.30.0).

Under the hood

Under the hood: a dict list in, dataclasses out, one SSE connection per turn

postprocess calls _check_format(value) and then converts every message into a dataclass: four dicts in, ChatbotDataMessages(root=[Message(role='user', metadata=None, content=[TextMessage(text='hi', type='text')], options=None), ...]) out, with an empty root for value=None. metadata is None unless you set it, which is exactly why the collapsible thought header shows up only on the messages that carry one. preprocess, the input direction, does the reverse trip - ChatbotDataMessages in, list of dicts out - and it refuses a hand-built dict list: c.preprocess([{'role':'user','content':'hi'}]) raises Error('Data incompatible with the messages format'). api_info() reports an array schema, so a Chatbot works fine as a /gradio_api endpoint with gradio_client. On the wire a whole streaming turn is two requests: POST /gradio_api/queue/join? carrying {"data":["stream please",[]],"fn_index":0,"trigger_id":2,"session_hash":"..."}, then one GET /gradio_api/queue/data?session_hash=... that stays open while every frame of your generator arrives on it - the dependency is flagged {"generator": true}, "connection": "sse", "stream_every": 0.5. And the component keeps nothing: the value in window.gradio_config is the server-rendered snapshot from launch, so the DOM afterwards is just your last return value.

Fun facts

Pros & cons

pros

  • + Every interaction comes back with an index: like, retry, undo, edit and select all identify which bubble was touched, so you never have to guess which turn the user meant.
  • + You own the transcript. A handler returns a list and that is what renders - no hidden history object to keep in step with the UI.
  • + Chat-UI batteries from one constructor: streaming, markdown and LaTeX, collapsible thought metadata, file previews, thumbs, retry, undo and editable messages.

cons

  • − No auto-append. Return the history you were given and the user's message never appears; every handler builds the whole list.
  • − The data format moved twice under existing code - tuples to messages, then the type switch deleted in 6.0.0 - so older snippets fail at runtime with a toast, not a warning.
  • − Affordances are per-listener and a wrong-typed argument fails quietly: no .retry() means no Retry button, and editable=True means no pencil.

Takeaways

  1. 1history = history + [{'role': 'user', 'content': msg}] and then return the whole list - nothing is appended for you, and the component renders exactly what comes back.
  2. 2Pass the transcript back in as an input as well as out - [msg, chat] -> [chat, msg] - so the handler can append to it and clear the box in the same trip.
  3. 3Write the handler as a generator (yield instead of return) and the same output component streams token by token over the existing queue connection.
  4. 4editable="user", not editable=True; the edit event gives you index, value and previous_value, and re-running the model on the corrected history is your code to write.
  5. 5Store votes against ev.index from .like() - and remember LikeData.value is a list, which holds an entire merged group of messages when consecutive turns were grouped.

Related commands

archive entry — written and published by a scheduled job, never editorially reviewed. Kept for reference: out of the site’s search index, and no ads run here. the reviewed library →

← all learning