Skip to main content

lib/omni/ui/agent_live.ex

defmodule Omni.UI.AgentLive do
  @moduledoc """
  A batteries-included LiveView for agent chat.

  Mounts a complete chat interface with a sessions drawer, files panel,
  and the built-in tools (Files, REPL, WebFetch, WebSearch) wired up
  via `Omni.UI.Agent`. Drop it into your router for a working agent UI
  with minimal setup:

      # router.ex
      forward "/omni_files", Omni.UI.Files.Plug
      live "/", Omni.UI.AgentLive

  The files Plug must be mounted so the files panel can serve agent-generated
  files (HTML documents and code artifacts). See `Omni.UI.Files.Plug`
  for token signing and configuration details.

  ## Configuration

  Configure via `config :omni_ui, Omni.UI.AgentLive`:

    * `:providers` — list of provider atoms. Calls `Omni.list_models/1`
      for each at mount time, silently skipping any that fail. The
      combined list populates the model selector. Defaults to `[]`.

    * `:default_model` — `{provider, model_id}` tuple for the model
      the agent starts with. If omitted, falls back to the first model
      returned by the configured providers. If set but not found in the
      provider models, logs a warning and falls back to the first
      available model.

  At least one of `:default_model` or a non-empty `:providers` list is
  required.

  ### Multi-provider setup

      config :omni_ui, Omni.UI.AgentLive,
        providers: [:anthropic, :ollama],
        default_model: {:anthropic, "claude-sonnet-4-6"}

  ### Fixed model (no selector)

  Configure only `:default_model` with no `:providers` to lock the
  model and hide the selector:

      config :omni_ui, Omni.UI.AgentLive,
        default_model: {:anthropic, "claude-sonnet-4-6"}

  For more control over layout, tools, or event handling, skip this
  module and `use Omni.UI` in your own LiveView instead.
  """

  use Phoenix.LiveView
  use Omni.UI

  require Logger

  alias Omni.UI.FilesComponent
  alias Phoenix.LiveView.JS

  attr :current_turn, Omni.UI.Turn
  attr :usage, Omni.Usage, required: true
  @impl Phoenix.LiveView
  def render(assigns) do
    ~H"""
    <div
      id="agent-live"
      phx-hook=".Responsive"
      class="relative h-screen w-full flex bg-omni-bg text-omni-text overflow-x-hidden">
      <.side_panel
        align="left"
        open={@open_sessions}
        close_event={JS.push("toggle", value: %{name: "sessions"})}>
        <.live_component
          module={Omni.UI.SessionsComponent}
          id="sessions"
          manager={Omni.UI.Sessions}
          current_id={@session_id} />
      </.side_panel>

      <.panel>
        <:header>
          <.chat_panel_header
            title={@title || "Untitled"}
            open_sessions={@open_sessions}
            open_files={@open_files} />
        </:header>

        <.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}
              model_options={@model_options}
              thinking={@thinking} />
          </:editor>

          <:footer>
            <.usage_block usage={@usage} />
          </:footer>
        </.chat_interface>
      </.panel>

      <.side_panel
        align="right"
        open={@open_files}
        close_event={JS.push("toggle", value: %{name: "files"})}
        outer_class="lg:w-96 xl:w-128 2xl:w-160"
        inner_class="w-screen md:w-96 xl:w-128 2xl:w-160">
        <.live_component
          module={FilesComponent}
          id="files-panel"
          session_id={@session_id} />
      </.side_panel>

      <.notifications stream={@streams.notifications} />
    </div>

    <script :type={Phoenix.LiveView.ColocatedHook} name=".Responsive">
      export default {
        mounted() {
          if (window.innerWidth >= 1024) {
            this.pushEvent("toggle", {name: "sessions"});
          }
        }
      }
    </script>
    """
  end

  # Chat panel header — extracted as a function component for readability,
  # not for reuse.
  attr :title, :string, required: true
  attr :open_sessions, :boolean
  attr :open_files, :boolean

  defp chat_panel_header(assigns) do
    ~H"""
    <.panel_header title={@title}>
      <:left>
        <button
          class={[
            "flex items-center justify-center size-8 rounded cursor-pointer",
            "text-omni-text-1 hover:text-omni-accent-1 hover:bg-omni-accent-2/10"
          ]}
          title="Sessions"
          phx-click={JS.push("toggle", value: %{name: "sessions"})}>
          <%= if @open_sessions do %>
            <Lucideicons.panel_left_close class="size-4" />
          <% else %>
            <Lucideicons.panel_left_open class="size-4" />
          <% end %>
        </button>
      </:left>

      <:right>
        <button
          class={[
            "flex items-center justify-center size-8 rounded cursor-pointer",
            "text-omni-text-1 hover:text-omni-accent-1 hover:bg-omni-accent-2/10"
          ]}
          title="Open files panel"
          phx-click={JS.push("toggle", value: %{name: "files"})}>
          <%= if @open_files do %>
            <Lucideicons.panel_right_close class="size-4" />
          <% else %>
            <Lucideicons.panel_right_open class="size-4" />
          <% end %>
        </button>
      </:right>
    </.panel_header>
    """
  end

  # Side panel — used only in the above render/1 function.
  attr :align, :string, values: ["left", "right"], required: true
  attr :open, :boolean, required: true
  attr :close_event, Phoenix.LiveView.JS, required: true
  attr :outer_class, :string, default: "lg:w-72"
  attr :inner_class, :string, default: "w-72"
  slot :inner_block, required: true

  defp side_panel(assigns) do
    ~H"""
    <div
      class={[
        "absolute top-12 bottom-0 w-0 lg:relative lg:inset-auto",
        "flex flex-col",
        "transition-[width] duration-300 ease-out",
        if(@align == "left", do: "left-0 z-20 items-start", else: "right-0 z-10 items-end"),
        if(@open, do: @outer_class),
      ]}>
      <div
        class={[
          "lg:hidden absolute -z-5 w-screen h-full bg-black/25 transition-opacity",
          if(@align == "right", do: "right-0"),
          if(@open, do: "visible opacity-100", else: "invisible opacity-0"),
        ]}
        phx-click={@close_event} />
      <div
        class={[
          "h-full transition-transform duration-300 ease-out",
          @inner_class,
          if(@open,
            do: "translate-x-0",
            else: if(@align == "left", do: "-translate-x-full", else: "translate-x-full")
          ),
          if(@align == "left",
            do: "border-r border-omni-border-2 shadow-[4px_0px_6px_-1px_rgba(0,0,0,0.1)]",
            else: "border-l border-omni-border-2 shadow-[-4px_0px_6px_-1px_rgba(0,0,0,0.1)]"
          )
        ]}>
        {render_slot(@inner_block)}
      </div>
    </div>
    """
  end

  @impl Phoenix.LiveView
  def mount(_params, _session, socket) do
    config = Application.get_env(:omni_ui, __MODULE__, [])
    model_options = list_provider_models(config)
    model = resolve_default_model(config, model_options)

    if connected?(socket), do: Omni.UI.Sessions.subscribe()

    {:ok,
     socket
     |> assign(
       model_options: model_options,
       open_sessions: false,
       open_files: false
     )
     |> init_session(
       agent_module: Omni.UI.Agent,
       tool_components: %{
         "files" => &Omni.UI.ToolsUI.files_tool_use/1,
         "repl" => &Omni.UI.ToolsUI.repl_tool_use/1
       },
       model: model
     )}
  end

  @impl Phoenix.LiveView
  def handle_params(params, _uri, socket) do
    if connected?(socket) do
      try do
        {:noreply, attach_session(socket, id: params["session_id"])}
      rescue
        _e in RuntimeError ->
          notify(:warning, "Session not found.")
          {:noreply, push_patch(socket, to: "/")}
      end
    else
      {:noreply, socket}
    end
  end

  @impl Phoenix.LiveView
  def handle_event("open_session", %{"session-id" => id}, socket) do
    {:noreply, push_patch(socket, to: "/?session_id=#{id}")}
  end

  def handle_event("new_session", _params, socket) do
    {:noreply, push_patch(socket, to: "/")}
  end

  def handle_event("open_file", %{"filename" => filename}, socket) do
    send_update(FilesComponent,
      id: "files-panel",
      action: {:view, filename}
    )

    {:noreply, assign(socket, :open_files, true)}
  end

  def handle_event("toggle", %{"name" => name}, socket) do
    key = String.to_existing_atom("open_#{name}")
    {:noreply, assign(socket, key, not socket.assigns[key])}
  end

  @impl Phoenix.LiveView
  def handle_info({:manager, _, _, _} = msg, socket) do
    send_update(Omni.UI.SessionsComponent, id: "sessions", manager_event: msg)
    {:noreply, socket}
  end

  def handle_info(:active_session_deleted, socket) do
    {:noreply, push_patch(socket, to: "/")}
  end

  @impl Omni.UI
  def session_event(:tool_result, %{name: name}, socket) when name in ["files", "repl"] do
    send_update(FilesComponent, id: "files-panel", action: :rescan)
    socket
  end

  def session_event(_event, _data, socket), do: socket

  defp list_provider_models(config) do
    config
    |> Keyword.get(:providers, [])
    |> Enum.flat_map(fn provider_id ->
      case Omni.list_models(provider_id) do
        {:ok, models} -> models
        {:error, _} -> []
      end
    end)
  end

  defp resolve_default_model(config, model_options) do
    case {Keyword.get(config, :default_model), model_options} do
      {{_, _} = ref, [model | _]} ->
        if model_exists?(ref, model_options) do
          ref
        else
          Logger.warning(
            "Omni.UI.AgentLive: configured :default_model #{inspect(ref)} " <>
              "not found in provider models, falling back to first available model"
          )

          model
        end

      {{_, _} = ref, []} ->
        ref

      {nil, [model | _]} ->
        model

      {nil, []} ->
        raise ArgumentError, """
        Omni.UI.AgentLive requires at least one of :default_model or \
        a non-empty :providers list.

        Configure in your application config:

            config :omni_ui, Omni.UI.AgentLive,
              providers: [:anthropic],
              default_model: {:anthropic, "claude-sonnet-4-6"}
        """
    end
  end

  defp model_exists?({provider_id, model_id}, model_options) do
    Enum.any?(model_options, fn model ->
      {p, id} = Omni.Model.to_ref(model)
      p == provider_id and id == model_id
    end)
  end
end