Build a streaming AI chat

Copy Markdown View Source

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:

    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

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:

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

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):

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

# 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

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:

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