Components Chat (AI)

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

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

Hi! I support bold, inline code, and links. How can I help?

What's the weather in Tokyo?
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: ""
     )}
  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

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

elixir
          # 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")
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>

        

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:

elixir
          # 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.

Searching the web
get_weather
Tokyo
21°C
☀️
Payment failed
heex
          <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.

heex
          <Chat.rich_text
  content={@text}
  render_widget={fn
    "weather", args -> ~H"<.weather_card city={args[\"city\"]} />"
    _, _ -> nil
  end}
/>

        
markdown
          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
First I considered the user's location, then looked up the current conditions and picked the most relevant detail.
heex
          <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.

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

heex
          <Chat.suggestions items={["Summarise this", "Write tests"]} on_select="suggest" />

        

Error

An error notice with an optional retry button.

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