OpenUI.LiveView.renderer/1 draws a program with your function components. It needs phoenix_live_view ~> 1.1.
<OpenUI.LiveView.renderer
id={"msg-#{@message.id}"}
response={@message.openui}
components={MyAppWeb.AssistantUI}
tool_provider={&MyApp.Tools.run/2}
streaming={@streaming?}
>
<:unknown :let={error}><p class="text-xs text-gray-500">{error.message}</p></:unknown>
</OpenUI.LiveView.renderer>| Attribute | Meaning |
|---|---|
id | Unique per program on the page. It also names the messages your LiveView receives. |
response | The program, or a stored message with ]]>openui: marker lines (see Stored programs). |
components | A module that uses OpenUI.LiveView.Components. |
tool_provider | Answers Query and Mutation (see below). Optional. |
streaming | true while the model is still writing. Queries run when it turns false. |
class | Class of the wrapper element. |
:unknown slot | Shown where the model used a component the catalog does not have. Without it, nothing shows. |
Each program is a LiveComponent with its own state, so one page can hold a whole chat of answers.
The components module
defmodule MyAppWeb.AssistantUI do
use OpenUI.LiveView.Components,
library: MyApp.Library,
fields: %{"Input" => [name: "name", type: "input"], "Select" => [name: "name", type: "select"]},
forms: %{"Form" => "name"}
def form(assigns), do: ~H|<fieldset>{@children}</fieldset>|
def input(assigns) do
~H"""
<label>
{@placeholder}
<input type="text" {@bind} />
<span :if={@error}>{error_text(@error)}</span>
</label>
"""
end
# ...one function per catalog component
endEvery catalog component needs a function named after it in snake case (LineChart is line_chart/1), or the module does not compile.
fieldsnames the components that are form fields: the prop holding the field name, and the component type stored with its value. A field may addrules: "prop"when its validation rules are not in a prop calledrules.formsnames the components that give a form name to their subtree, and the prop that holds it.
The renderer wraps the program in a <form>. Render a form component as a <fieldset> or a <div>, never a nested <form>.
Assigns
Each function receives:
- Every catalog prop by its name, as a plain value. A prop that takes components arrives as rendered HTML (a list of it for a list prop): render it with
{@children}. Data that lands there at runtime, such as a query result written in a children list, is left out; text and numbers show as text. A bound prop arrives with its current value. - Action props as button attributes:
<button {@action || []}>. The attribute list isnilwhen the model gave no action. You never deal withphx-clickorphx-target. @bindfor field components:id,nameandvaluefor the input,<input {@bind} />. Typing updates the bound$variableor the form field.@errorfor field components: the validation rule the field failed at the last submit ("required","email","minLength"...), ornil. Write the message in your own language.@loading: true while a query of the program is loading.
So bind, loading and error cannot be catalog prop names.
An action inside a form that sends something (@ToAssistant, a library action or a mutation) runs only when every field of that form passes its rules.
Messages to your LiveView
def handle_info({:openui, _id, %OpenUI.Event{type: "continue_conversation"} = event}, socket) do
{:noreply, ask(socket, OpenUI.Message.from_event(event))}
end
def handle_info({:openui, id, {:state, state}}, socket) do
{:noreply, save_state(socket, id, state)}
end
def handle_info({:openui, _id, {:errors, errors}}, socket) do
{:noreply, assign(socket, :openui_errors, errors)}
end%OpenUI.Event{}:@ToAssistant(type: "continue_conversation"),@OpenUrl("open_url") and actions of your library (their own name). It carries the form state as of when the action ran.{:state, state}: the person changed something. Store it to restore the program later, including after a reconnect: the program state lives in the LiveComponent, so without it a reconnect loses what the person typed.{:errors, errors}: the error list changed;[]means it recovered. Send it back to the model in the next request when you want it to fix its program.
Query and Mutation
Query("tool", args, defaults) and Mutation("tool", args) call your tools through tool_provider:
# a map of tools
tool_provider = %{"list_tickets" => fn args -> {:ok, MyApp.Tickets.list(args)} end}
# or one function
def run("list_tickets", args), do: {:ok, MyApp.Tickets.list(args)}
def run(_name, _args), do: {:error, {:tool_not_found, ["list_tickets"]}}Each call runs in its own task with start_async. A query runs when the stream ends and again when a $variable written in its arguments changes; a result for arguments that changed meanwhile is dropped. A query with a refresh interval runs again on a timer. An exception in a tool becomes the query's or mutation's error; the page stays up.
Results can be any Elixir data: atom keys, Decimal, Date and structs are turned into JSON-like values.
What it does not do
- A catalog component cannot render a LiveComponent: the renderer turns each component into HTML inside a rescue, and a LiveComponent cannot be turned into HTML. One that tries is dropped and reported.
- A component that changes is sent again whole; there is no diff inside it.