ai simulated intermediate

Jido Browser Docs Scout Agent

Copy-pasteable Jido.Browser example showing plugin-backed browser sessions, deterministic docs navigation, content extraction, and screenshots.

primary showcase simulated ai browser jido_browser copy-pasteable

Related guides and notebooks

lib/agent_jido_web/examples/browser_docs_scout_agent_live.ex 252 lines
defmodule AgentJidoWeb.Examples.BrowserDocsScoutAgentLive do
  @moduledoc """
  Interactive docs scout demo powered by `Jido.Browser.Plugin`.
  """

  use AgentJidoWeb, :live_view

  alias AgentJido.Demos.BrowserDocsScout.SimulatedAdapter
  alias AgentJido.Demos.BrowserDocsScoutAgent
  alias Jido.Agent.Directive

  @overview_url SimulatedAdapter.overview_url()

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:agent, BrowserDocsScoutAgent.new())
     |> assign(:history, [])
     |> assign(:overview_url, @overview_url)
     |> assign(:last_error, nil)}
  end

  @impl true
  def render(assigns) do
    browser_state = BrowserDocsScoutAgent.plugin_state(assigns.agent, Jido.Browser.Plugin) || %{}
    session = Map.get(browser_state, :session)
    current_page = assigns.agent.state.current_page
    screenshot = assigns.agent.state.screenshot

    assigns =
      assigns
      |> assign(:browser_state, browser_state)
      |> assign(:session, session)
      |> assign(:current_page, current_page)
      |> assign(:screenshot, screenshot)

    ~H"""
    <div id="browser-docs-scout-demo" class="rounded-lg border border-border bg-card p-6 space-y-6">
      <div class="flex items-center justify-between gap-4">
        <div>
          <div class="text-sm font-semibold text-foreground">Jido Browser Docs Scout Agent</div>
          <div class="text-[11px] text-muted-foreground">
            Plugin-backed browser session using a deterministic simulated adapter
          </div>
        </div>
        <div class="text-[10px] font-mono text-muted-foreground bg-elevated px-2 py-1 rounded border border-border">
          {if @session, do: "session: " <> String.slice(@session.id, 0, 8) <> "…", else: "session: idle"}
        </div>
      </div>

      <div class="rounded-md border border-border bg-elevated p-4 space-y-2 text-xs text-muted-foreground">
        <div>
          Dependency:
          <code class="font-mono text-foreground">
            {~s({:jido_browser, github: "agentjido/jido_browser", branch: "main"})}
          </code>
        </div>
        <div>
          Adapter today: `AgentJido.Demos.BrowserDocsScout.SimulatedAdapter`
        </div>
        <div>
          Swap to `Jido.Browser.Adapters.Vibium` or `Jido.Browser.Adapters.Web` in your own project.
        </div>
      </div>

      <div class="flex gap-3 flex-wrap">
        <button
          phx-click="open_intro"
          class="px-4 py-2 rounded-md bg-primary/10 border border-primary/30 text-primary hover:bg-primary/20 transition-colors text-sm font-semibold"
        >
          Open Plugin Guide
        </button>
        <button
          phx-click="extract_article"
          class="px-4 py-2 rounded-md bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/20 transition-colors text-sm font-semibold"
        >
          Extract Article
        </button>
        <button
          phx-click="follow_link"
          class="px-4 py-2 rounded-md bg-accent-cyan/10 border border-accent-cyan/30 text-accent-cyan hover:bg-accent-cyan/20 transition-colors text-sm font-semibold"
        >
          Follow Testing Link
        </button>
        <button
          phx-click="capture_screenshot"
          class="px-4 py-2 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-300 hover:bg-amber-500/20 transition-colors text-sm font-semibold"
        >
          Capture Screenshot
        </button>
        <button
          phx-click="reset_demo"
          class="px-3 py-2 rounded-md bg-elevated border border-border text-muted-foreground hover:text-foreground hover:border-primary/40 transition-colors text-xs"
        >
          Reset
        </button>
      </div>

      <div :if={@last_error} class="rounded-md border border-red-400/30 bg-red-400/10 p-3">
        <div class="text-xs font-semibold text-red-300">{@last_error.summary}</div>
        <div class="text-xs text-red-200/80 mt-1">{@last_error.detail}</div>
      </div>

      <div class="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
        <div class="space-y-4">
          <div class="rounded-md border border-border bg-elevated p-4 space-y-2">
            <div class="text-[10px] uppercase tracking-wider text-muted-foreground">Current Page</div>
            <div class="text-sm font-semibold text-foreground">
              {Map.get(@current_page, :title, "No docs page opened yet")}
            </div>
            <div class="text-xs text-muted-foreground break-all">
              {Map.get(@current_page, :url, "Ready to open the simulated plugin guide.")}
            </div>
            <div :if={Map.get(@current_page, :description)} class="text-xs text-muted-foreground">
              {Map.get(@current_page, :description)}
            </div>
          </div>

          <div class="rounded-md border border-border bg-elevated p-4 space-y-2">
            <div class="flex items-center justify-between">
              <div class="text-[10px] uppercase tracking-wider text-muted-foreground">Extracted Content</div>
              <div class="text-[10px] text-muted-foreground">
                {if @agent.state.content_length > 0, do: "#{@agent.state.content_length} chars", else: "idle"}
              </div>
            </div>
            <pre class="text-[12px] text-foreground whitespace-pre-wrap min-h-40 font-mono"><%= if @agent.state.extracted_content == "", do: "Run Extract Article to capture markdown from the active page.", else: @agent.state.extracted_content %></pre>
          </div>
        </div>

        <div class="space-y-4">
          <div class="rounded-md border border-border bg-elevated p-4 space-y-2">
            <div class="text-[10px] uppercase tracking-wider text-muted-foreground">Screenshot</div>
            <div :if={@screenshot == %{}} class="text-xs text-muted-foreground">
              Capture Screenshot to render the deterministic PNG returned by the adapter.
            </div>
            <img
              :if={Map.get(@screenshot, :base64)}
              src={"data:#{Map.get(@screenshot, :mime)};base64,#{Map.get(@screenshot, :base64)}"}
              alt="Simulated browser screenshot"
              class="w-full rounded border border-border bg-background"
            />
            <div :if={Map.get(@screenshot, :size)} class="text-[11px] text-muted-foreground">
              {Map.get(@screenshot, :size)} bytes
            </div>
          </div>

          <div class="rounded-md border border-border bg-elevated p-4">
            <div class="flex items-center justify-between mb-2">
              <div class="text-[10px] uppercase tracking-wider text-muted-foreground">Run History</div>
              <div class="text-[10px] text-muted-foreground">{length(@history)} step(s)</div>
            </div>
            <div :if={@history == []} class="text-xs text-muted-foreground">
              Open the plugin guide to start the deterministic browser flow.
            </div>
            <div :if={@history != []} class="space-y-2 max-h-64 overflow-y-auto">
              <%= for entry <- @history do %>
                <div class="rounded-md border border-border bg-background/70 px-3 py-2">
                  <div class="text-[11px] font-semibold text-foreground">{entry.label}</div>
                  <div class="text-[11px] text-muted-foreground">{entry.detail}</div>
                </div>
              <% end %>
            </div>
          </div>
        </div>
      </div>
    </div>
    """
  end

  @impl true
  def handle_event("open_intro", _params, socket) do
    {:noreply,
     run_action(socket, fn agent -> BrowserDocsScoutAgent.open_page(agent, @overview_url) end, fn agent ->
       %{
         label: "Open Plugin Guide",
         detail: "Opened #{Map.get(agent.state.current_page, :title, "the docs guide")} with a simulated browser session."
       }
     end)}
  end

  def handle_event("extract_article", _params, socket) do
    {:noreply,
     run_action(socket, &BrowserDocsScoutAgent.extract_current_page/1, fn agent ->
       %{
         label: "Extract Article",
         detail: "Captured #{agent.state.content_length} characters of #{agent.state.extracted_format} from the active docs page."
       }
     end)}
  end

  def handle_event("follow_link", _params, socket) do
    {:noreply,
     run_action(
       socket,
       fn agent ->
         BrowserDocsScoutAgent.follow_link(agent, "a[data-doc-link='testing']", text: "Testing browser agents")
       end,
       fn agent ->
         %{label: "Follow Testing Link", detail: "Navigated to #{Map.get(agent.state.current_page, :title, "the next docs page")}."}
       end
     )}
  end

  def handle_event("capture_screenshot", _params, socket) do
    {:noreply,
     run_action(socket, &BrowserDocsScoutAgent.capture_screenshot/1, fn agent ->
       size = get_in(agent.state, [:screenshot, :size]) || 0
       %{label: "Capture Screenshot", detail: "Stored a deterministic PNG payload (#{size} bytes)."}
     end)}
  end

  def handle_event("reset_demo", _params, socket) do
    {:noreply,
     run_action(socket, &BrowserDocsScoutAgent.reset_browser/1, fn _agent ->
       %{label: "Reset Browser", detail: "Cleared the browser session and local demo outputs."}
     end)}
  end

  defp run_action(socket, action_fun, entry_fun) do
    {new_agent, directives} = action_fun.(socket.assigns.agent)

    case first_error_message(directives) do
      nil ->
        entry =
          entry_fun.(new_agent)
          |> Map.put(:at, DateTime.utc_now())

        socket
        |> assign(:agent, new_agent)
        |> assign(:last_error, nil)
        |> assign(:history, [entry | socket.assigns.history])

      error_message ->
        entry = %{label: "Action Error", detail: error_message, at: DateTime.utc_now()}

        socket
        |> assign(:agent, new_agent)
        |> assign(:last_error, %{summary: "Browser action failed.", detail: error_message})
        |> assign(:history, [entry | socket.assigns.history])
    end
  end

  defp first_error_message(directives) do
    Enum.find_value(directives, fn
      %Directive.Error{error: %{message: message}} -> message
      %Directive.Error{error: error} -> inspect(error)
      _ -> nil
    end)
  end
end