Chat (AI)
AI Elements for Phoenix. A composition-first family for building streaming chat UIs - the kind you'd reach for assistant-ui or Vercel AI Elements for in React - without a client AI SDK. Tokens stream over the LiveView socket you already have: the model streams to your LiveView process, you push each delta to the bubble, and the component owns its own DOM so nothing clobbers the text as it arrives.
Three-minute setup
-
The Chat family is not
pulled in by
use PetalComponents(its generic names likemarkdown/1would clash with your own helpers). Alias it:alias PetalComponents.Chat, then call it namespaced -<Chat.conversation>. -
Register the bundled JS hooks in
app.js(import PetalComponents from "../../deps/petal_components/assets/js/petal_components"thenhooks: { ...PetalComponents }) - they drive token streaming, the composer, and copy buttons. -
For rendered markdown, add the optional
{:mdex, "~> 0.12"}dependency.
Quick start
A complete chat: a scrollable thread, user and assistant bubbles, and a composer pinned to the bottom. Drop in conversation, chat_message and prompt_input and you have the UI.
defmodule MyAppWeb.ChatLive do
use MyAppWeb, :live_view
alias PetalComponents.Chat
@stream_id "answer"
def mount(_params, _session, socket) do
{:ok,
assign(socket,
messages: [%{role: "assistant", text: "Hi! How can I help?"}],
draft: "",
streaming?: false,
buffer: ""
)}
end
def handle_event("draft", %{"prompt" => prompt}, socket) do
{:noreply, assign(socket, draft: prompt)}
end
def handle_event("send", %{"prompt" => prompt}, socket) do
lv = self()
# Stream the reply in a task; forward each token back to this LiveView.
Task.start(fn -> MyApp.LLM.stream(prompt, lv) end)
{:noreply,
socket
|> update(:messages, &(&1 ++ [%{role: "user", text: prompt}]))
|> assign(draft: "", streaming?: true, buffer: "")}
end
# Your LLM client sends these two messages to the LiveView pid.
def handle_info({:llm_delta, text}, socket) do
{:noreply,
socket
|> push_event("pc-chat-token", %{id: @stream_id, text: text})
|> update(:buffer, &(&1 <> text))}
end
def handle_info(:llm_done, socket) do
{:noreply,
socket
|> update(:messages, &(&1 ++ [%{role: "assistant", text: socket.assigns.buffer}]))
|> assign(streaming?: false, buffer: "")}
end
end
<Chat.conversation id="chat">
<Chat.chat_message :for={msg <- @messages} role={msg.role}>
<span class="pc-chat__text">{msg.text}</span>
</Chat.chat_message>
<Chat.chat_message :if={@streaming?} role="assistant">
<Chat.streaming_text id={@stream_id} />
</Chat.chat_message>
<:footer>
<Chat.prompt_input
phx-submit="send"
phx-change="draft"
value={@draft}
loading={@streaming?}
on_stop="stop"
/>
</:footer>
</Chat.conversation>
Streaming tokens
This is the whole architecture. The streaming_text
bubble carries the PetalChatStream
hook and owns its own DOM (phx-update="ignore"), so LiveView never re-renders over the text. You push_event/3
each delta with the event name the bubble listens for (default "pc-chat-token") and a payload of %{id: <bubble id>, text: <delta>}. When the reply finishes, commit the buffered text as a normal chat_message
and drop the streaming bubble.
# Per token from your provider (OpenAI/Anthropic/Gemini ...):
socket = push_event(socket, "pc-chat-token", %{id: "answer", text: delta})
# In the template, the matching bubble:
# <Chat.streaming_text id="answer" />
Want the textarea to stay editable while a reply streams, with the send button turning into a stop button? Pass loading
and on_stop
to prompt_input
- wire on_stop
to cancel your generation task.
Markdown
Assistant replies are usually markdown. Render a committed
message with markdown/1
- sanitized and syntax-highlighted via MDEx. Output is sanitized server-side, so model text is never rendered as live markup.
Forecast
Tokyo is 21°C and sunny.
- Light breeze
- UV index moderate
IO.puts("pack light")
<Chat.chat_message :for={msg <- @messages} role={msg.role}>
<Chat.markdown :if={msg.role == "assistant"} content={msg.text} />
<span :if={msg.role != "assistant"} class="pc-chat__text">{msg.text}</span>
</Chat.chat_message>
To render markdown as it streams
(headings and code appear while typing), use a format="markdown"
bubble and push throttled HTML instead of raw text. Chat.to_html/1
uses the same engine as markdown/1:
# In the template:
# <Chat.streaming_text id={@stream_id} format="markdown" />
# Accumulate tokens, then render the buffer on a throttle (~100ms):
def handle_info(:flush_md, socket) do
html = Chat.to_html(socket.assigns.buffer)
{:noreply, push_event(socket, "pc-chat-token", %{id: @stream_id, html: html})}
end
Tool calls (generative UI)
When your model supports function calling, render the result as a real Phoenix component inside a tool_call
card. The model emits data, you map the tool name to one of your components and render it - so the widget can have its own phx-click, forms and streams. status
drives the header: :running
shows a spinner, :complete
a check, :error
a warning.
<Chat.tool_call name="get_weather" status={:complete}>
<.weather_card city={@args["city"]} temp={@result.temp} />
</Chat.tool_call>
Rich text (inline widgets)
"MDX for Phoenix." rich_text
renders normal markdown, but the model can also drop a widget mid-prose with a fenced ```widget:<name>
block of JSON args. You supply a render_widget
function mapping a name + args to a rendered component; everything else renders as markdown (normal code fences are untouched).
Here's what I found. Normal markdown still works, and a widget can appear inline below.
Pack an umbrella just in case.
<Chat.rich_text
content={@text}
render_widget={fn
"weather", args -> ~H"<.weather_card city={args[\"city\"]} />"
_, _ -> nil
end}
/>
Here's the forecast:
```widget:weather
{"city": "Paris"}
```
Pack an umbrella.
Reasoning, actions, suggestions, errors
Reasoning
A collapsible "thinking" block for reasoning-model output.
Thought for 2s
<Chat.reasoning label="Thought for 2s">Chain of thought here...</Chat.reasoning>
Message actions + copy button
A row of actions under a reply. copy_button copies text client-side via a bundled hook.
<Chat.message_actions>
<Chat.copy_button id={"copy-#{@id}"} text={@text} />
<button type="button" class="pc-chat__action" phx-click="regenerate">Regenerate</button>
</Chat.message_actions>
Suggestions
Prompt-starter chips for the empty state. Each pushes on_select with phx-value-prompt.
<Chat.suggestions items={["Summarise this", "Write tests"]} on_select="suggest" />
Error
An error notice with an optional retry button.
<Chat.chat_error on_retry="retry">Something went wrong.</Chat.chat_error>
Styling
Every component takes a class
(appended last, so your utilities win), exposes --pc-chat-*
CSS variables for theming, and accepts slots for full markup replacement. Full per-component reference and the complete streaming guide live on
hexdocs
.
Properties
conversation/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| id | :string | - |
| class | :any | nil |
| [all additional properties] | Passed to the container. Slots: inner_block (required), footer (pinned below the scroll area). | |
chat_message/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| role | :string | "user", "assistant", "system" |
| class | :any | nil |
| [all additional properties] | Slots: avatar (optional leading avatar/icon), inner_block (required). | |
streaming_text/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| id* | :string | - |
| event | :string | pc-chat-token |
| format | :string | "text", "markdown" |
| class | :any | nil |
| [all additional properties] | Unused. | |
prompt_input/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| id | :string | - |
| name | :string | prompt |
| value | :string | "" |
| placeholder | :string | Send a message... |
| aria_label | :string | Message |
| loading | :boolean | false |
| on_stop | :string | - |
| submit_label | :string | Send |
| class | :any | nil |
| [all additional properties] | Globals incl. phx-submit, phx-change, phx-target. Slot: actions (extra controls left of the send button). | |
markdown/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| content* | :string | - |
| id | :string | - |
| class | :any | nil |
| [all additional properties] | Requires the optional :mdex dependency. Unused otherwise. | |
tool_call/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| name* | :string | - |
| status | :atom | "running", "complete", "error" |
| label | :string | nil |
| class | :any | nil |
| [all additional properties] | Slot: inner_block (the rendered widget / tool result). | |
rich_text/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| content* | :string | - |
| render_widget | :any | nil |
| class | :any | nil |
| [all additional properties] | Unused. | |
reasoning/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| label | :string | Reasoning |
| open | :boolean | false |
| class | :any | nil |
| [all additional properties] | Slot: inner_block (required). | |
message_actions/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| class | :any | nil |
| [all additional properties] | Slot: inner_block (required). | |
copy_button/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| id* | :string | - |
| text* | :string | - |
| label | :string | Copy |
| class | :any | nil |
| [all additional properties] | Unused. | |
suggestions/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| items* | :list | - |
| on_select | :string | suggestion |
| class | :any | nil |
| [all additional properties] | Unused. | |
chat_error/1
Defaults are indicated in bold. Required fields have * before the label. Hover over the name for docs.
| Name | Type | Options/Default |
|---|---|---|
| on_retry | :string | nil |
| retry_label | :string | Retry |
| class | :any | nil |
| [all additional properties] | Slot: inner_block (required). | |
Chat.to_html/1
A function (not a component): Chat.to_html(content)
renders a markdown string to sanitized, syntax-highlighted HTML using the same engine as markdown/1. Use it to live-stream markdown - push the result to a format="markdown"
streaming_text bubble. Requires :mdex.