defmodule Omni.UI do
@moduledoc """

**Agent chat UI for Elixir** — a ready-made LiveView interface for
exploring, prototyping, and experimenting with
[Omni Agent](https://github.com/aaronrussell/omni_agent) powered
agents.
Omni.UI gives you two ways to work:
| Approach | What you get |
| --- | --- |
| `Omni.UI.AgentLive` | A batteries-included LiveView — mount it in your router and you have a working agent chat with sessions, files, REPL, and web tools |
| `use Omni.UI` | A macro that injects session streaming, state management, and event routing into your own LiveView — you bring the template and any custom behaviour |
`AgentLive` is the fastest path to seeing Omni in action. When you need
full control over layout, tools, or event handling, drop down to the
macro and the `Omni.UI.ChatUI` / `Omni.UI.CoreUI` components.
## Installation
Add Omni UI to your dependencies:
def deps do
[
{:omni_ui, "~> 0.1"}
]
end
Omni UI depends on `omni`, which provides the LLM API layer. Configure
your provider API keys as described in the
[Omni README](https://github.com/aaronrussell/omni#installation).
### Requirements
Omni UI uses colocated CSS and JavaScript (extracted at compile time
by the `:phoenix_live_view` compiler). This requires:
- Phoenix 1.8+
- Phoenix LiveView 1.2+
- Tailwind 4.2.3+
New Phoenix applications generated from 1.8.8 onwards are ready out
of the box.
### Assets
Omni UI ships its CSS and JavaScript as colocated assets — no static
files to copy. Your application imports them from the
`phoenix-colocated` build output.
In your CSS entry point, import the colocated stylesheet and add a
`@source` directive so Tailwind can scan the component templates:
/* assets/css/app.css */
@import "phoenix-colocated/omni_ui/colocated.css";
@source "../../deps/omni_ui/lib";
In your JavaScript entry point, import the colocated hooks and spread
them into your LiveSocket:
// assets/js/app.js
import {hooks as omniHooks} from "phoenix-colocated/omni_ui"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...omniHooks},
// ...
})
Both require your bundler's module resolution to include
`Mix.Project.build_path()`. For esbuild and Tailwind, this is
configured via the `NODE_PATH` environment variable, which is the
default for Phoenix 1.8+ applications.
The CSS defines OKLCH semantic colour tokens with light and dark
variants. Override any token to match your application's palette.
### Syntax highlighting
Omni UI uses mdex for Markdown rendering with syntax highlighting
powered by lumis. Enable it in your application config:
# config/config.exs
config :mdex_native, syntax_highlighter: :lumis
## Quick start with AgentLive
Configure `Omni.UI.Sessions` (the shipped session manager), add it
to your supervision tree, and mount `Omni.UI.AgentLive` in your
router:
# config/config.exs
config :mdex_native, syntax_highlighter: :lumis
config :omni_ui, Omni.UI.Sessions,
store: {Omni.Session.Stores.FileSystem, base_dir: "priv/sessions"},
title_generator: {:anthropic, "claude-haiku-4-5"}
config :omni_ui, Omni.UI.AgentLive,
providers: [:anthropic],
default_model: {:anthropic, "claude-sonnet-4-6"}
# application.ex
children = [
# ... your other children
Omni.UI.Sessions,
]
# router.ex
scope "/" do
pipe_through :browser
live "/", Omni.UI.AgentLive
end
forward "/omni_files", Omni.UI.Files.Plug
Start your server and open the browser — you have a working agent
chat with streaming, branching, a files panel, an Elixir REPL, and
web tools. See `Omni.UI.AgentLive` for configuration options.
## Building a custom LiveView
`use Omni.UI` in your own LiveView for full control over the template,
tools, and event handling. The macro injects `handle_event/3` and
`handle_info/2` clauses that route session events and UI interactions
automatically — your own handlers compose alongside them via
`defoverridable`.
defmodule MyAppWeb.ChatLive do
use Phoenix.LiveView
use Omni.UI
def render(assigns) do
~H\"\"\"
<.chat_interface>
<.turn_list stream={@streams.turns} tool_components={@tool_components} />
<.turn :if={@current_turn} turn={@current_turn} tool_components={@tool_components} />
<:editor>
<.editor model={@model} />
</:editor>
</.chat_interface>
\"\"\"
end
def mount(_params, _session, socket) do
{:ok, init_session(socket, model: {:anthropic, "claude-sonnet-4-6"})}
end
def handle_params(params, _uri, socket) do
{:noreply, attach_session(socket, id: params["session_id"])}
end
end
Three functions drive the session lifecycle:
1. **`init_session/2`** — called once in `mount/3`. Sets up all
Omni.UI assigns (model, tools, streams, etc.) but does **not**
create a session — `:session` is `nil` after this call.
2. **`attach_session/2`** — called in `handle_params/3`. When the
URL contains a session id, opens that session from the store and
subscribes the LiveView. When the id is `nil` (e.g. the user
navigates to `/`), resets to a blank state with no session.
3. **`ensure_session/1`** — called automatically by the macro when
the user sends their first message. If a session is already
attached, this is a no-op. Otherwise it creates a new session on
the fly. This lazy-creation avoids piling up empty draft sessions
every time someone refreshes the page.
A typical lifecycle looks like:
mount/3 → init_session (config only, no session)
handle_params/3 → attach_session(id: nil) # blank page
user sends "hi" → ensure_session # session created now
→ prompt sent to session
user clicks a → handle_params/3
session link → attach_session(id: "abc") # detach old, attach new
### Rendering with components
`use Omni.UI` imports all components from `Omni.UI.ChatUI` (chat
pipeline — `chat_interface`, `editor`, `turn_list`, `turn`,
`user_message`, `assistant_message`, `content_block`, `markdown`)
and `Omni.UI.CoreUI` (shared primitives — `expandable`, `select`,
`version_nav`, `notifications`). Compose them freely in your
template.
### The session_event callback
After each session event is processed by the macro's default
handlers, your module's `session_event/3` callback is called with
the already-updated socket. Use it to layer on custom behaviour —
analytics, side effects, additional assigns:
@impl Omni.UI
def session_event(:turn, {:stop, response}, socket) do
MyApp.Analytics.track(response.usage)
socket
end
def session_event(_event, _data, socket), do: socket
## Sessions
Conversations are managed by an `Omni.Session.Manager`. Omni.UI
ships `Omni.UI.Sessions` as the default — configure a store, add it
to your supervision tree, and `attach_session/2` uses it
automatically. For multi-tenant apps or custom isolation, define
your own manager module and pass it via the `:manager` option to
`init_session/2`. See `Omni.UI.Sessions` for configuration details.
Sessions are created lazily on the first prompt (via
`ensure_session/1`), so refreshing the page without sending a
message doesn't pile up untouched drafts. The macro handles
subscription, snapshot reconstruction for mid-stream joins, and
event routing — the LiveView is a subscriber, not the owner.
Persistence, branching, and idle shutdown are all `Omni.Session`
concerns.
## Custom agents and tools
By default, `AgentLive` uses `Omni.UI.Agent` which wires in the
Files, REPL, WebFetch, and WebSearch tools. When building your own
LiveView, pass a custom `Omni.Agent` module via `:agent_module` to
`init_session/2`, or pass tools directly via `:tools`:
init_session(socket,
model: {:anthropic, "claude-sonnet-4-6"},
agent_module: MyApp.Agent,
tools: [my_tool, {another_tool, component: &MyAppWeb.ToolUI.render/1}]
)
Tools can be paired with a component function that replaces the
default content-block rendering for that tool's uses. Alternatively,
pass a `:tool_components` map for tools added by the agent module's
`init/1` callback. See `init_session/2` for the full options
reference.
## Theming
Omni UI's shipped CSS defines semantic colour tokens using OKLCH
values, with automatic light/dark variants:
| Token | Role |
| --- | --- |
| `--color-omni-bg`, `--color-omni-bg-1` ... `-bg-2` | Background surfaces |
| `--color-omni-text`, `--color-omni-text-1` ... `-text-4` | Text hierarchy |
| `--color-omni-border-1` ... `-border-3` | Border weights |
| `--color-omni-accent-1` ... `-accent-2` | Accent / interactive |
Override any token in your own CSS to match your application's
palette. All components reference these tokens through Tailwind
classes (`bg-omni-bg`, `text-omni-text-1`, etc.), so a single
override propagates everywhere.
## Configuration
Application-level config is namespaced under `:omni_ui`:
# Tool execution timeouts (ms)
config :omni_ui,
tool_timeouts: %{"repl" => 120_000},
default_tool_timeout: 15_000
# Session manager
config :omni_ui, Omni.UI.Sessions,
store: {Omni.Session.Stores.FileSystem, base_dir: "priv/sessions"},
title_generator: {:anthropic, "claude-haiku-4-5"}
Per-session configuration (model, tools, thinking, system prompt) is
set via `init_session/2` options and can be updated at runtime with
`update_session/2`.
## State ownership
Two sets of assigns live on a LiveView using `Omni.UI`:
- **Omni.UI-owned**: session lifecycle (`:session`, `:session_id`,
`:title`, `:tree`, `:current_turn`), agent config (`:manager`,
`:agent_module`, `:model`, `:thinking`, `:system`, `:tools`,
`:tool_timeout`, `:tool_components`), UI state (`:usage`,
`:url_synced`, `:notification_ids`), and the `:turns` and
`:notifications` streams. Initialised by `init_session/2`
in `mount/3`; mutated by `attach_session/2`, `ensure_session/1`,
`update_session/2`, and the macro-injected handlers.
- **Consumer-owned**: anything else — UI-driven state (`:model_options`,
view toggles, etc.), application data, custom event handlers, routing.
The rule: if `mount/3` is setting an Omni.UI-owned assign directly,
reach for `init_session/2` instead.
"""
require Logger
import Phoenix.Component
import Phoenix.LiveView, only: [stream: 3, stream: 4]
import Omni.Util, only: [maybe_put: 3]
@tool_timeouts %{
"repl" => 65_000,
"bash" => 35_000,
"web_fetch" => 20_000
}
@default_tool_timeout 10_000
# ── Behaviour ─────────────────────────────────────────────────────
@doc """
Called after Omni.UI's default handling for each session event.
The LiveView receives events from the `Omni.Session` it is subscribed
to. These include agent lifecycle events (`:turn`, `:text_delta`,
`:tool_result`, `:error`, etc.) and session-level events (`:tree`,
`:store`, `:title`). Omni.UI handles each event first — updating
streams, assigns, and UI state — then calls this callback with the
already-updated socket so the consumer can layer on additional logic.
Must return the socket (possibly with additional assign mutations).
"""
@callback session_event(event :: atom(), data :: term(), Phoenix.LiveView.Socket.t()) ::
Phoenix.LiveView.Socket.t()
# ── Macro ──────────────────────────────────────────────────────────
defmacro __using__(_opts) do
quote do
@behaviour Omni.UI
@before_compile Omni.UI
import Omni.UI.ChatUI
import Omni.UI.CoreUI
import Omni.UI,
only: [
init_session: 2,
attach_session: 2,
ensure_session: 1,
update_session: 2,
notify: 2,
notify: 3
]
end
end
defmacro __before_compile__(env) do
has_handle_event = Module.defines?(env.module, {:handle_event, 3})
has_handle_info = Module.defines?(env.module, {:handle_info, 2})
has_session_event = Module.defines?(env.module, {:session_event, 3})
event_clauses = inject_handle_event(has_handle_event)
info_clauses = inject_handle_info(has_handle_info)
session_event_clause = unless has_session_event, do: inject_default_session_event()
quote do
unquote(event_clauses)
unquote(info_clauses)
unquote(session_event_clause)
end
end
defp inject_handle_event(has_existing) do
overridable =
if has_existing do
quote do
defoverridable handle_event: 3
end
end
fallthrough =
if has_existing do
quote do
def handle_event(event, params, socket), do: super(event, params, socket)
end
end
quote do
unquote(overridable)
def handle_event("omni:" <> _ = event, params, socket) do
Omni.UI.Handlers.handle_event(event, params, socket)
end
unquote(fallthrough)
end
end
defp inject_handle_info(has_existing) do
overridable =
if has_existing do
quote do
defoverridable handle_info: 2
end
end
fallthrough =
if has_existing do
quote do
def handle_info(message, socket), do: super(message, socket)
end
end
quote do
unquote(overridable)
def handle_info({Omni.UI, :new_message, _message} = msg, socket) do
Omni.UI.Handlers.handle_info(msg, socket)
end
def handle_info({Omni.UI, :edit_message, _turn_id, _message} = msg, socket) do
Omni.UI.Handlers.handle_info(msg, socket)
end
def handle_info({Omni.UI, :notify, _notification} = msg, socket) do
Omni.UI.Handlers.handle_info(msg, socket)
end
def handle_info({Omni.UI, :dismiss_notification, _id} = msg, socket) do
Omni.UI.Handlers.handle_info(msg, socket)
end
def handle_info({:session, pid, event, data}, socket) do
# Drop stale events from a session we've since detached from. After a
# session switch, the old session's queued events may linger in our
# mailbox; processing them would mutate the new session's assigns.
if pid == socket.assigns[:session] do
socket = Omni.UI.Handlers.handle_session_event(event, data, socket)
socket =
case __MODULE__.session_event(event, data, socket) do
%Phoenix.LiveView.Socket{} = s ->
s
other ->
raise "#{inspect(__MODULE__)}.session_event/3 must return a socket, got: #{inspect(other)}"
end
{:noreply, socket}
else
{:noreply, socket}
end
end
unquote(fallthrough)
end
end
defp inject_default_session_event do
quote do
@impl Omni.UI
def session_event(_event, _data, socket), do: socket
end
end
# ── Public API ─────────────────────────────────────────────────────
@doc """
Initialises every Omni.UI-owned assign and stream on the socket.
Call this once from `mount/3`. Sets the agent-config assigns
(`:manager`, `:model`, `:thinking`, `:system`, `:tools`, `:tool_timeout`,
`:tool_components`), the session-state assigns (`:session`, `:session_id`,
`:title`, `:tree`, `:current_turn`, `:usage`, `:url_synced`), the
notification list (`:notification_ids`), and initialises the `:turns` and
`:notifications` streams. The session itself is `nil` after this call —
it's attached either by `attach_session/2` (when `handle_params/3`
receives a `session_id`) or by `ensure_session/1` (lazily, on the first
`:new_message`).
## Options
* `:model` (required) — `%Omni.Model{}` struct or `{provider_id, model_id}` tuple
* `:manager` — Manager module (default `Omni.UI.Sessions`). Must be
running under the application supervision tree with a configured store.
* `:agent_module` — module that `use`s `Omni.Agent` (default `nil`,
meaning the stock `Omni.Agent`). Use this to bake in tools, system
prompt, or other defaults via the agent's `init/1` callback.
* `:thinking` — thinking mode: `false | :low | :medium | :high | :max` (default: `false`)
* `:system` — system prompt string (default: `nil`)
* `:tools` — list of tool entries (default: `[]`). Each entry is either:
* a bare `%Omni.Tool{}` struct (rendered with the default content block), or
* `{%Omni.Tool{}, opts}` where `opts` is a keyword list. The only supported
option is `component: (assigns -> rendered)` — a 1-arity function component
that replaces the default content block rendering for that tool's uses.
* `:tool_components` — map of `tool_name => (assigns -> rendered)` for tools
that aren't constructed by the consumer (typically tools added by an
`:agent_module`'s `init/1` callback). Merged with components extracted
from `:tools` entries; this map wins on key conflicts.
* `:tool_timeout` — tool execution timeout. Either an integer in ms
(applied to all tools) or a 1-arity function receiving the tool name
and returning a timeout. When omitted, defaults to `&Omni.UI.tool_timeout/1`
which returns per-tool values matched to the built-in omni_tools defaults.
See `tool_timeout/1` for override options via application config.
## Example
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:model_options, my_model_options())
|> Omni.UI.init_session(model: {:anthropic, "claude-sonnet-4-5"})}
end
"""
@spec init_session(Phoenix.LiveView.Socket.t(), keyword()) :: Phoenix.LiveView.Socket.t()
def init_session(socket, opts) do
manager = Keyword.get(opts, :manager, Omni.UI.Sessions)
agent_module = Keyword.get(opts, :agent_module)
model = resolve_model!(Keyword.fetch!(opts, :model))
thinking = Keyword.get(opts, :thinking, false)
system = Keyword.get(opts, :system)
{tools, tool_components} = normalise_tools(Keyword.get(opts, :tools, []))
tool_components = Map.merge(tool_components, Keyword.get(opts, :tool_components, %{}))
tool_timeout = Keyword.get(opts, :tool_timeout)
socket
|> assign(
manager: manager,
agent_module: agent_module,
model: model,
thinking: thinking,
system: system,
tools: tools,
tool_timeout: tool_timeout,
tool_components: tool_components,
session: nil,
session_id: nil,
title: nil,
tree: nil,
current_turn: nil,
usage: %Omni.Usage{},
notification_ids: [],
url_synced: false
)
|> stream(:turns, [])
|> stream(:notifications, [])
end
@doc """
Connects the LiveView to a session, loading its tree, title, and usage
into the socket assigns.
Call from `handle_params/3` after `init_session/2` has set the defaults
in `mount/3`. The function opens the session via the configured Manager,
atomically subscribes-with-snapshot, and populates the session-state
assigns. Raises if the id isn't found in the store (wrap in
`try/rescue` to handle gracefully).
If an existing session is already attached, it is detached first
(releasing its `:controller` hold so it can idle-shutdown). Idempotent
for the same `:id` — re-entering with the currently-attached id is a
no-op, so `push_patch` to the same URL doesn't churn the subscription.
When `:id` is `nil` or omitted, the socket is reset to the blank state
with no session attached. This is the normal path when the user
navigates to a URL with no session id (e.g. `/`). The session will be
created lazily by `ensure_session/1` when the user sends their first
message.
Reads agent configuration (`:manager`, `:model`, `:thinking`, `:system`,
`:tools`, `:tool_timeout`) from the assigns set by `init_session/2`.
## Example
def handle_params(params, _uri, socket) do
if connected?(socket) do
try do
{:noreply, attach_session(socket, id: params["session_id"])}
rescue
_ -> {:noreply, push_navigate(socket, to: "/")}
end
else
{:noreply, socket}
end
end
"""
@spec attach_session(Phoenix.LiveView.Socket.t(), keyword()) :: Phoenix.LiveView.Socket.t()
def attach_session(socket, opts) do
session_id = socket.assigns[:session_id]
case Keyword.get(opts, :id) do
id when is_nil(id) ->
detach_previous_session(socket)
blank_session(socket)
id when id == session_id ->
socket
id ->
detach_previous_session(socket)
a = socket.assigns
agent_opts =
build_agent_opts(
a.agent_module,
a.model,
a.thinking,
a.system,
a.tools,
a.tool_timeout
)
session = open_session!(a.manager, id, agent_opts)
{:ok, snapshot} = Omni.Session.subscribe(session, mode: :controller)
apply_snapshot(socket, session, snapshot, url_synced: true)
end
end
@doc """
Ensures `socket.assigns.session` is set, creating a fresh session via the
configured Manager if it is `nil`.
Used by the macro's `:new_message` handler to lazily create the session
on first prompt — so a user opening `/` and refreshing doesn't spawn
untouched draft sessions.
Reads agent configuration from the assigns populated by `init_session/2`
(`:manager`, `:model`, `:thinking`, `:system`, `:tools`, `:tool_timeout`).
Subscribes the calling LiveView as `:controller` atomically with the
snapshot.
Returns the socket unchanged when a session is already attached.
"""
@spec ensure_session(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
def ensure_session(socket) do
case socket.assigns[:session] do
pid when is_pid(pid) ->
socket
_ ->
a = socket.assigns
agent_opts =
build_agent_opts(
a.agent_module,
a.model,
a.thinking,
a.system,
a.tools,
a.tool_timeout
)
{:ok, pid} = a.manager.create(subscribe: false, agent: agent_opts)
{:ok, snapshot} = Omni.Session.subscribe(pid, mode: :controller)
apply_snapshot(socket, pid, snapshot, url_synced: false)
end
end
defp blank_session(socket) do
socket
|> assign(
session: nil,
session_id: nil,
title: nil,
tree: nil,
current_turn: nil,
usage: %Omni.Usage{},
url_synced: false
)
|> stream(:turns, [], reset: true)
end
defp apply_snapshot(socket, pid, snapshot, opts) do
url_synced = Keyword.get(opts, :url_synced, false)
resolved_model = snapshot.agent.state.model
resolved_thinking = Keyword.get(snapshot.agent.state.opts, :thinking, socket.assigns.thinking)
turns = Omni.UI.Turn.all(snapshot.tree)
usage = Omni.Session.Tree.usage(snapshot.tree)
current_turn = rebuild_current_turn(snapshot)
socket
|> assign(
session: pid,
session_id: snapshot.id,
title: snapshot.title,
tree: snapshot.tree,
current_turn: current_turn,
model: resolved_model,
thinking: resolved_thinking,
usage: usage,
url_synced: url_synced
)
|> stream(:turns, turns, reset: true)
end
defp build_agent_opts(agent_module, model, thinking, system, tools, tool_timeout) do
opts =
[model: model, opts: [thinking: thinking], tool_timeout: &tool_timeout/1]
|> maybe_put(:system, system)
|> maybe_put(:tools, tools)
|> maybe_put(:tool_timeout, tool_timeout)
case agent_module do
nil -> opts
mod when is_atom(mod) -> {mod, opts}
end
end
# If the snapshot was taken while the agent is mid-turn, reconstruct a
# streaming `Omni.UI.Turn` from the pending messages and the in-flight
# partial assistant message. Subsequent streaming events from the session
# then accumulate into this turn correctly.
defp rebuild_current_turn(snapshot) do
case snapshot.agent.pending do
[] ->
nil
[_ | _] = pending ->
messages = pending ++ List.wrap(snapshot.agent.partial)
nil
|> Omni.UI.Turn.new(messages, %Omni.Usage{})
|> Map.put(:status, :streaming)
end
end
# Pass `subscribe: false` to the Manager and subscribe ourselves above — the
# explicit `Omni.Session.subscribe/2` call atomically pairs subscribe with
# snapshot, eliminating a race where streaming events could fire between
# Manager-internal subscribe and our subsequent `get_snapshot/1`.
defp open_session!(manager, id, agent_opts) do
case manager.open(id, subscribe: false, agent: agent_opts) do
{:ok, pid, _started_or_existing} -> pid
{:error, :not_found} -> raise "Omni.Session #{inspect(id)} not found"
end
end
# Drop the controller hold on the previous session so it can idle-shutdown,
# and stop receiving events that would race against the new session's
# `current_turn`. Tolerant of a dead/missing prior session.
defp detach_previous_session(socket) do
case socket.assigns[:session] do
nil ->
:ok
pid when is_pid(pid) ->
try do
Omni.Session.unsubscribe(pid)
catch
:exit, _ -> :ok
end
end
end
@doc """
Updates session/agent configuration on a running system.
Accepts any subset of options. For each provided option, updates the
appropriate combination of socket assign and session state.
## Options
* `:model` — updates both socket assign and the session's agent model
* `:thinking` — updates both socket assign and the session's agent opts
* `:system` — updates the session's agent system prompt (not surfaced in UI)
* `:tools` — updates the session's agent tools and the `:tool_components` assign
## Example
Omni.UI.update_session(socket, model: {:anthropic, "claude-opus-4-20250514"})
When called before a session has been attached (e.g. the user picks a
model on the blank `/` page before sending the first prompt), updates
the assigns only — the value is then passed to `Omni.Session` at
`ensure_session/1` time.
"""
@spec update_session(Phoenix.LiveView.Socket.t(), keyword()) :: Phoenix.LiveView.Socket.t()
def update_session(socket, opts) do
Enum.reduce(opts, socket, fn
{:model, value}, socket ->
# A bad model ref here usually means a session persisted a model that
# has since been deregistered (renamed, provider removed, etc.). Skip
# the update and keep the current model rather than raising.
case resolve_model(value) do
{:ok, model} ->
maybe_set_agent(socket, :model, model)
assign(socket, :model, model)
{:error, reason} ->
Logger.warning(
"update_session: ignoring unresolvable model #{inspect(value)} (#{inspect(reason)})"
)
notify(:warning, "Previous model is no longer available — keeping the current model.")
socket
end
{:thinking, thinking}, socket ->
maybe_set_agent(socket, :opts, &Keyword.put(&1, :thinking, thinking))
assign(socket, :thinking, thinking)
{:system, system}, socket ->
maybe_set_agent(socket, :system, system)
assign(socket, :system, system)
{:tools, entries}, socket ->
{tools, tool_components} = normalise_tools(entries)
maybe_set_agent(socket, :tools, tools)
socket
|> assign(:tools, tools)
|> assign(:tool_components, tool_components)
end)
end
defp maybe_set_agent(socket, key, value) do
case socket.assigns[:session] do
pid when is_pid(pid) -> Omni.Session.set_agent(pid, key, value)
_ -> :ok
end
end
@doc """
Pushes a notification to the calling LiveView's toaster.
Must be called from within the LiveView process (including from child
LiveComponents, whose `self()` is the parent LiveView). The LiveView must
be using `use Omni.UI` — the macro injects the `handle_info` clauses that
receive the message, and `attach_session/2` initialises the stream.
If the consumer does not render `<.notifications>` in their template, the
notification is still accepted and auto-dismissed but is not visible.
## Levels
* `:info` — neutral informational message
* `:success` — confirmation of a completed action
* `:warning` — something went wrong but was handled
* `:error` — something failed
## Options
* `:timeout` — ms until auto-dismiss (default `5000`)
## Example
Omni.UI.notify(:warning, "Couldn't auto-generate a title.")
Omni.UI.notify(:error, "Save failed", timeout: 10_000)
"""
@spec notify(Omni.UI.Notification.level(), String.t(), keyword()) :: :ok
def notify(level, message, opts \\ [])
when level in [:info, :success, :warning, :error] do
send(self(), {Omni.UI, :notify, Omni.UI.Notification.new(level, message, opts)})
:ok
end
@doc """
Returns the agent-level tool timeout (in ms) for the given tool name.
Used as the default `tool_timeout` function passed to `Omni.Agent`
via `build_agent_opts/6`. Each built-in tool's default is its own
internal timeout plus a 5 s buffer, so the tool can timeout gracefully
before the agent kills the task.
Built-in defaults: `repl` 65 000, `bash` 35 000, `web_fetch` 20 000.
All other tools fall back to 10 000.
Override per-tool or the fallback via application config:
config :omni_ui,
tool_timeouts: %{"repl" => 120_000},
default_tool_timeout: 15_000
Consumers who pass a custom `:tool_timeout` to `init_session/2` bypass
this function entirely.
"""
@spec tool_timeout(String.t()) :: pos_integer()
def tool_timeout(tool_name) do
overrides = Application.get_env(:omni_ui, :tool_timeouts, %{})
default = Application.get_env(:omni_ui, :default_tool_timeout, @default_tool_timeout)
Map.get(overrides, tool_name, Map.get(@tool_timeouts, tool_name, default))
end
# ── Private ────────────────────────────────────────────────────────
@doc false
# Splits a list of tool entries into a flat `[%Omni.Tool{}]` list (for the
# agent) and a `%{tool_name => component_fun}` map (for the UI). Accepts
# either bare `%Omni.Tool{}` structs or `{%Omni.Tool{}, opts}` tuples where
# `opts[:component]` is a 1-arity function component. Order of the flat tool
# list is preserved. Public (undocumented) for testability.
def normalise_tools(entries) do
{tools, components} =
Enum.reduce(entries, {[], %{}}, fn
%Omni.Tool{} = tool, {tools, components} ->
{[tool | tools], components}
{%Omni.Tool{} = tool, opts}, {tools, components} when is_list(opts) ->
components =
case Keyword.get(opts, :component) do
nil -> components
fun when is_function(fun, 1) -> Map.put(components, tool.name, fun)
end
{[tool | tools], components}
end)
{Enum.reverse(tools), components}
end
defp resolve_model!(model) do
case resolve_model(model) do
{:ok, model} -> model
{:error, reason} -> raise ArgumentError, "failed to resolve model: #{inspect(reason)}"
end
end
defp resolve_model(%Omni.Model{} = model), do: {:ok, model}
defp resolve_model({provider_id, model_id}), do: Omni.get_model(provider_id, model_id)
end