PetalComponents.Chat (petal_components v4.0.3)

Copy Markdown View Source

AI chat / conversation components — the LiveView-native answer to React's AI Elements / assistant-ui. Build streaming chat UIs without a client AI SDK: tokens stream over the LiveView socket you already have.

Components:

Importing

Unlike the core components, Chat is not brought in by use PetalComponents — it defines generic names (markdown/1, reasoning/1, …) that would clash with your app's own helpers. Alias it and call it namespaced:

alias PetalComponents.Chat

<Chat.conversation id="chat">
  <Chat.chat_message role="assistant"><Chat.markdown content={@text} /></Chat.chat_message>
</Chat.conversation>

The examples below use that Chat. prefix.

Streaming

streaming_text/1 is driven by the bundled PetalChatStream JS hook. The parent LiveView pushes each delta and the hook appends it to the bubble:

# per Gemini/OpenAI delta:
socket = push_event(socket, "pc-chat-token", %{id: "answer", text: delta})

<Chat.streaming_text id="answer" />

Register the hooks once in your LiveSocket:

import PetalComponents from "../../deps/petal_components/assets/js/petal_components"
new LiveSocket("/live", Socket, { hooks: { ...PetalComponents }, ... })

Styling

Every component takes a class that is appended last (CSS specificity wins, matching the rest of petal_components). Theme tokens are exposed as CSS variables (--pc-chat-*) for reskinning without touching markup, and any part can be fully replaced via slots.

Summary

Functions

An error notice with an optional retry button.

A single message bubble.

A scrollable conversation thread. Composition-first: drop chat_message/1, streaming_text/1, or your own markup inside.

A copy-to-clipboard button (via the PetalCopy hook). Shows brief "Copied!" feedback. Requires a unique id.

Renders markdown as sanitized, syntax-highlighted HTML (via MDEx). Use it for committed assistant messages so headings, lists, tables and code blocks render properly

A row of actions under a message (copy, regenerate, feedback). Compose with copy_button/1 and your own phx-click buttons using the pc-chat__action class.

The composer. Wraps a form; pass phx-submit (and optionally phx-change) through the global attrs.

A collapsible "thinking" / reasoning block for reasoning-model output. Native <details>, so no JS.

Markdown with inline widget directives ("MDX for Phoenix").

Token-by-token streaming output, driven by the PetalChatStream JS hook.

Clickable prompt-starter chips for an empty state. Each pushes on_select with phx-value-prompt set to the suggestion.

Render markdown to sanitized, syntax-highlighted HTML using the same engine the markdown/1 component uses. Use it to live-stream markdown: throttle calls on your growing buffer and push_event the result to a format="markdown" streaming_text/1

A tool-call card — the chrome around a generative-UI widget.

Functions

chat_error(assigns)

An error notice with an optional retry button.

<Chat.chat_error on_retry="retry">Something went wrong.</Chat.chat_error>

Attributes

  • on_retry (:string) - Defaults to nil.
  • retry_label (:string) - Defaults to "Retry".
  • class (:any) - Defaults to nil.

Slots

  • inner_block (required)

chat_message(assigns)

A single message bubble.

Default markup, or replace it entirely — the class is appended last so your utilities win, and the :inner_block is yours to fill.

Attributes

  • role (:string) - Defaults to "assistant". Must be one of "user", "assistant", or "system".
  • class (:any) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • avatar - optional leading avatar/icon.
  • inner_block (required)

conversation(assigns)

A scrollable conversation thread. Composition-first: drop chat_message/1, streaming_text/1, or your own markup inside.

<Chat.conversation>
  <Chat.chat_message :for={msg <- @messages} role={msg.role}>{msg.text}</Chat.chat_message>
  <:footer>
    <Chat.prompt_input phx-submit="send" loading={@streaming?} />
  </:footer>
</Chat.conversation>

Attributes

  • id (:string) - defaults to a generated id so multiple threads can coexist.
  • class (:any) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)
  • footer - pinned below the scroll area, e.g. a prompt_input.

copy_button(assigns)

A copy-to-clipboard button (via the PetalCopy hook). Shows brief "Copied!" feedback. Requires a unique id.

Attributes

  • id (:string) (required)
  • text (:string) (required) - the text to copy.
  • label (:string) - Defaults to "Copy".
  • class (:any) - Defaults to nil.

markdown(assigns)

Renders markdown as sanitized, syntax-highlighted HTML (via MDEx). Use it for committed assistant messages so headings, lists, tables and code blocks render properly:

<Chat.chat_message role="assistant"><Chat.markdown content={msg.text} /></Chat.chat_message>

Output is sanitized server-side — model text is never rendered as live markup.

Markdown is rendered faithfully

Code blocks come from the model's own fences. If a model wraps an example that itself contains a ``` fence inside another same-length ``` fence, that is invalid CommonMark and renders broken (the outer fence closes early) — every CommonMark renderer behaves this way. Steer the model with a system prompt: "when showing example markdown that contains code fences, wrap the outer block in MORE backticks than the inner fence."

Attributes

  • content (:string) (required)
  • id (:string) - pass a unique id to enable per-code-block copy buttons. Defaults to nil.
  • class (:any) - Defaults to nil.

message_actions(assigns)

A row of actions under a message (copy, regenerate, feedback). Compose with copy_button/1 and your own phx-click buttons using the pc-chat__action class.

<Chat.message_actions>
  <Chat.copy_button id={"copy-#{@id}"} text={@text} />
  <button class="pc-chat__action" phx-click="regenerate">Regenerate</button>
</Chat.message_actions>

Attributes

  • class (:any) - Defaults to nil.

Slots

  • inner_block (required)

prompt_input(assigns)

The composer. Wraps a form; pass phx-submit (and optionally phx-change) through the global attrs.

<Chat.prompt_input phx-submit="send" phx-change="draft" value={@draft} loading={@streaming?} on_stop="stop" />

While loading, the input stays editable (so you can draft your next message) and the send button becomes a stop button that pushes on_stop — wire it to cancel your generation task.

Attributes

  • id (:string) - defaults to a generated id so multiple composers can coexist.
  • name (:string) - Defaults to "prompt".
  • value (:string) - Defaults to "".
  • placeholder (:string) - Defaults to "Send a message...".
  • aria_label (:string) - accessible label for the textarea. Defaults to "Message".
  • loading (:boolean) - Defaults to false.
  • on_stop (:string) - event pushed when the stop button is clicked while loading. Defaults to nil.
  • submit_label (:string) - Defaults to "Send".
  • class (:any) - Defaults to nil.
  • Global attributes are accepted. Supports all globals plus: ["phx-submit", "phx-change", "phx-target"].

Slots

  • actions - extra controls left of the send button.

reasoning(assigns)

A collapsible "thinking" / reasoning block for reasoning-model output. Native <details>, so no JS.

<Chat.reasoning>Chain of thought here...</Chat.reasoning>
<Chat.reasoning label="Thought for 3s" open>...</Chat.reasoning>

Attributes

  • label (:string) - Defaults to "Reasoning".
  • open (:boolean) - Defaults to false.
  • class (:any) - Defaults to nil.

Slots

  • inner_block (required)

rich_text(assigns)

Markdown with inline widget directives ("MDX for Phoenix").

The model can drop a widget mid-prose with a fenced block tagged ```widget:<name> containing JSON args. Everything else renders as normal markdown (normal code fences like ```elixir are untouched). You supply a render_widget function that maps a name + args to a rendered component:

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

Example model output:

Here's the forecast:

```widget:weather
{"city": "Paris"}
```

Pack an umbrella.

Attributes

  • content (:string) (required)
  • render_widget (:any) - fn(name :: String.t(), args :: map) -> rendered | nil. Defaults to nil.

  • class (:any) - Defaults to nil.

streaming_text(assigns)

Token-by-token streaming output, driven by the PetalChatStream JS hook.

Render this in the in-progress assistant bubble. The parent LiveView pushes each delta to it:

socket = push_event(socket, "pc-chat-token", %{id: "answer", text: delta})

<Chat.streaming_text id="answer" />

Until the first token lands it shows a typing indicator; on the first token it swaps to live text with a blinking caret. The element owns its own DOM (phx-update="ignore"), so no re-render clobbers the streamed text.

Attributes

  • id (:string) (required)
  • event (:string) - push_event name the hook listens for. Defaults to "pc-chat-token".
  • format (:string) - "text" appends raw token deltas; "markdown" replaces innerHTML with rendered HTML you push (see to_html/1). Defaults to "text". Must be one of "text", or "markdown".
  • class (:any) - Defaults to nil.

suggestions(assigns)

Clickable prompt-starter chips for an empty state. Each pushes on_select with phx-value-prompt set to the suggestion.

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

Attributes

  • items (:list) (required)
  • on_select (:string) - event pushed with phx-value-prompt. Defaults to "suggestion".
  • class (:any) - Defaults to nil.

to_html(content)

Render markdown to sanitized, syntax-highlighted HTML using the same engine the markdown/1 component uses. Use it to live-stream markdown: throttle calls on your growing buffer and push_event the result to a format="markdown" streaming_text/1:

socket = push_event(socket, "pc-chat-token", %{id: "answer", html: PetalComponents.Chat.to_html(buffer)})

tool_call(assigns)

A tool-call card — the chrome around a generative-UI widget.

This is the "AI Elements" pattern done LiveView-native: the model emits a structured tool call (function calling), you map the tool name to one of your registered Phoenix components, and render it inside this card. The widget is a real LiveView component — it can have its own phx-click, forms, streams.

<Chat.tool_call name="get_weather" status={:complete}>
  <.weather_card city={@args["city"]} temp={@result.temp} />
</Chat.tool_call>

status drives the header affordance: :running shows a spinner, :complete a check, :error a warning.

Attributes

  • name (:string) (required)
  • status (:atom) - Defaults to :complete. Must be one of :running, :complete, or :error.
  • label (:string) - human label; defaults to the tool name. Defaults to nil.
  • class (:any) - Defaults to nil.

Slots

  • inner_block - the rendered widget / tool result.