Packages
petal_components
4.1.2
4.6.2
4.6.1
4.6.0
4.5.0
4.4.0
4.3.0
4.2.1
4.2.0
4.1.2
4.1.1
4.1.0
4.0.12
4.0.11
4.0.10
4.0.6
4.0.5
4.0.4
4.0.3
4.0.1
3.2.2
3.2.1
3.2.0
3.1.0
3.0.2
3.0.1
3.0.0
2.9.3
2.9.2
retired
2.9.1
retired
2.9.0
retired
2.8.4
2.8.3
2.8.2
2.8.1
2.8.0
2.7.4
2.7.3
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.2
2.5.1
2.5.0
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.1
2.2.0
2.1.2
2.1.1
2.1.0
2.0.6
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.9.3
1.9.2
1.9.1
1.9.0
1.8.0
1.7.1
1.7.0
1.6.2
1.6.1
1.6.0
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.14
1.2.13
1.2.12
1.2.11
1.2.10
1.2.9
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.19.10
0.19.9
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.0
0.14.1
0.14.0
0.13.7
0.13.6
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.8
0.10.7
0.10.6
0.10.5
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.1
0.2.0
0.1.0
Shadcn-style Phoenix LiveView components that AI assistants can actually use. Pair with the MCP server so AI coding tools can inspect the real component API.
Current section
Files
Jump to
Current section
Files
guides/streaming_chat.md
# Build a streaming AI chat
The `PetalComponents.Chat` family lets you build a streaming chat UI — 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.
This guide builds a working chat: a thread, streaming assistant replies, and a
composer. It's provider-agnostic — wherever you see `MyApp.LLM.stream/2`, plug
in your own OpenAI/Anthropic/Gemini client.
## Prerequisites
- petal_components installed (see the README), including the JS hooks step:
```js
import PetalComponents from "../../deps/petal_components/assets/js/petal_components"
const liveSocket = new LiveSocket("/live", Socket, { hooks: { ...PetalComponents } })
```
- For rendered markdown replies, the optional `{:mdex, "~> 0.12"}` dependency.
> #### Chat is opt-in {: .info}
>
> Unlike the core components, the `Chat` family is **not** pulled in by
> `use PetalComponents` — it defines generic names like `markdown/1` that would
> clash with your own helpers. Bring it in explicitly and call it namespaced:
>
> ```elixir
> alias PetalComponents.Chat
> # then <Chat.conversation>, <Chat.chat_message>, <Chat.markdown>, …
> ```
## The shape
The component family is composition-first:
- `conversation/1` — the scrollable thread + a `:footer` slot for the composer
- `chat_message/1` — a user/assistant bubble (with an optional `:avatar` slot)
- `streaming_text/1` — the in-progress assistant bubble; the `PetalChatStream`
hook appends tokens you `push_event` to it
- `prompt_input/1` — the composer (Enter to send, Shift+Enter for a newline)
The key idea: **the streaming bubble owns its own DOM** (`phx-update="ignore"`),
so you push token deltas straight to it and LiveView never clobbers them.
## The LiveView
```elixir
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: "",
task: nil
)}
end
# Keep the textarea value in sync so we can clear it on send.
def handle_event("draft", %{"prompt" => prompt}, socket) do
{:noreply, assign(socket, draft: prompt)}
end
def handle_event("send", %{"prompt" => prompt}, socket) do
prompt = String.trim(prompt)
if prompt == "" or socket.assigns.streaming? do
{:noreply, socket}
else
lv = self()
# Stream in a task; forward each delta back to this LiveView.
{:ok, pid} = Task.start(fn -> MyApp.LLM.stream(prompt, lv) end)
{:noreply,
socket
|> update(:messages, &(&1 ++ [%{role: "user", text: prompt}]))
|> assign(draft: "", streaming?: true, buffer: "", task: pid)}
end
end
# Cancel: kill the task and keep whatever streamed so far.
def handle_event("stop", _params, socket) do
if pid = socket.assigns.task, do: Process.exit(pid, :kill)
{:noreply, commit(socket, socket.assigns.buffer)}
end
# Your LLM client sends these messages to the LiveView pid.
def handle_info({:llm_delta, text}, %{assigns: %{streaming?: true}} = socket) do
{:noreply,
socket
|> push_event("pc-chat-token", %{id: @stream_id, text: text})
|> update(:buffer, &(&1 <> text))}
end
# Ignore stray deltas that arrive after a stop/commit.
def handle_info({:llm_delta, _text}, socket), do: {:noreply, socket}
def handle_info(:llm_done, socket), do: {:noreply, commit(socket, socket.assigns.buffer)}
defp commit(socket, text) do
socket =
if String.trim(text) == "",
do: socket,
else: update(socket, :messages, &(&1 ++ [%{role: "assistant", text: text}]))
assign(socket, streaming?: false, buffer: "", task: nil)
end
def render(assigns) do
~H"""
<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>
"""
end
end
```
That's a complete streaming chat. The contract with your LLM client is two
messages to the LiveView pid: `{:llm_delta, text}` per token and `:llm_done`
when finished.
## Rendering markdown
Assistant replies are usually markdown. Render the **committed** message with
`markdown/1` (sanitized + syntax-highlighted via MDEx):
```heex
<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>
```
### Streaming markdown live
To render markdown *as it streams* (headings/code appear while typing), use a
`format="markdown"` streaming bubble and push throttled HTML instead of raw
text. `PetalComponents.Chat.to_html/1` uses the same engine as `markdown/1`:
```elixir
# in render
<Chat.streaming_text id={@stream_id} format="markdown" />
# accumulate tokens, then render the buffer on a throttle (~100ms)
def handle_info({:llm_delta, text}, %{assigns: %{streaming?: true}} = socket) do
socket = update(socket, :buffer, &(&1 <> text))
socket =
if socket.assigns.flush_scheduled do
socket
else
Process.send_after(self(), :flush_md, 100)
assign(socket, flush_scheduled: true)
end
{:noreply, socket}
end
def handle_info(:flush_md, %{assigns: %{streaming?: true}} = socket) do
html = PetalComponents.Chat.to_html(socket.assigns.buffer)
{:noreply,
socket
|> push_event("pc-chat-token", %{id: @stream_id, html: html})
|> assign(flush_scheduled: false)}
end
def handle_info(:flush_md, socket), do: {:noreply, assign(socket, flush_scheduled: false)}
```
> #### Steer the model on fenced markdown {: .tip}
>
> When you ask a model to "show markdown", it tends to nest same-length code
> fences (` ```markdown ` wrapping ` ```elixir `), which is invalid CommonMark
> and renders broken in every renderer. A system prompt fixes it: "when showing
> example markdown that contains code fences, wrap the outer block in more
> backticks than the inner fence."
## Tool calls → widgets (generative UI)
When your model supports function calling, render the result as a real Phoenix
component inside a `tool_call/1` card — the model emits *data*, you map it to UI:
```heex
<Chat.tool_call name="get_weather" status={:complete}>
<.weather_card city={@city} temp={@temp} />
</Chat.tool_call>
```
Because the widget is a LiveView component, it can have its own `phx-click`,
forms, and streams — not just static markup.
## Other pieces
- `reasoning/1` — a collapsible "thinking" block for reasoning-model output.
- `suggestions/1` — prompt-starter chips for the empty state.
- `message_actions/1` + `copy_button/1` — a copy/regenerate row under a reply.
- `chat_error/1` — an error notice with a retry button.
## 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. See `PetalComponents.Chat` for the per-component reference.