Powered by AppSignal & Oban Pro

Pado Chat

pado_kino/livebooks/pado_chat.livemd

Pado Chat

Mix.install([
  {:pado, path: Path.expand("../../pado", __DIR__)},
  {:pado_kino, path: Path.expand("..", __DIR__)}
])

Authenticate

cd pado && mix pado.llm.login --output ~/.config/pado/openai_codex.json

Configure Agent

alias Pado.AgentConfig.LLM
alias Pado.LLM.Catalog.OpenAICodex
alias Pado.LLM.Credential.FileLoader
alias Pado.LLM.Message.User

# 1. 크레덴셜 로드
creds_path = Path.expand("~/.config/pado/openai_codex.json")

creds =
  case FileLoader.load(creds_path) do
    {:ok, creds} ->
      creds

    {:error, :enoent} ->
      raise "Credentials file not found: #{creds_path}. Run: cd pado && mix pado.llm.login --output #{creds_path}"

    {:error, reason} ->
      raise "Failed to load credentials: #{inspect(reason)}"
  end

# 2. 에이전트와 세션
agent = %Pado.AgentConfig{
  llm: %Pado.AgentConfig.LLM{
    provider: :openai_codex,
    credentials: creds,
    model: OpenAICodex.default()
  },
  harness: %Pado.AgentConfig.Harness{tools: [Pado.AgentConfig.Tools.Bash.tool(timeout: 10)]}
}

session_id = "session-#{System.unique_integer([:positive])}"

# 3. 누적되는 메시지 상태 (Elixir Agent로 보관)
{:ok, state_pid} = Elixir.Agent.start_link(fn -> [] end)
{:ok, running_agent_pid} = Elixir.Agent.start_link(fn -> nil end)

# 4. UI 위젯
chat_frame = Kino.Frame.new()

model_options =
  Pado.LLM.Catalog.OpenAICodex.all()
  |> Enum.map(fn m -> {m.id, m.name} end)

model_input =
  Kino.Input.select("모델", model_options,
    default: Pado.LLM.Catalog.OpenAICodex.default().id
  )

effort_input =
  Kino.Input.select(
    "효과",
    [{:none, "none"}, {:low, "low"}, {:medium, "medium"}, {:high, "high"}, {:xhigh, "xhigh"}],
    default: :medium
  )

input = Kino.Input.textarea("메시지")

form =
  Kino.Control.form(
    [model: model_input, effort: effort_input, prompt: input],
    submit: "보내기",
    reset_on_submit: [:prompt]
  )

abort_button = Kino.Control.button("Abort")

wait_until_subscriber_count = fn pid, count ->
  Enum.reduce_while(1..50, :error, fn _, _ ->
    try do
      case :sys.get_state(pid) do
        %{subscribers: subscribers} when map_size(subscribers) == count ->
          {:halt, :ok}

        _ ->
          Process.sleep(10)
          {:cont, :error}
      end
    catch
      :exit, _ -> {:halt, :error}
    end
  end)
end

# 5. 입력 처리
Kino.listen(abort_button, fn _event ->
  case Elixir.Agent.get(running_agent_pid, & &1) do
    pid when is_pid(pid) -> GenServer.cast(pid, :abort_job)
    _ -> :ok
  end
end)

Kino.listen(form, fn %{data: %{model: model_id, effort: effort, prompt: text}} ->
  text = String.trim(text)

  if text != "" do
    model = Pado.LLM.Catalog.OpenAICodex.get(model_id)
    opts = Keyword.put(agent.llm.opts, :reasoning_effort, effort)
    current_agent = %{agent | llm: %{agent.llm | model: model, opts: opts}}

    msgs0 = Elixir.Agent.get(state_pid, & &1) ++ [User.new(text)]
    Kino.Frame.append(chat_frame, Kino.Markdown.new("**🙂 나**\n\n" <> text))

    job = %Pado.Agent.Job{
      messages: msgs0,
      session_id: session_id,
      job_id: "job-#{System.unique_integer([:positive])}"
    }

    tool_result_text = fn %Pado.LLM.Message.ToolResult{content: parts} ->
      parts
      |> Enum.map(fn
        {:text, t} -> t
        other -> inspect(other)
      end)
      |> Enum.join("\n")
    end

    render_reply = fn frame, thinking, text ->
      thinking_block =
        if thinking == "" do
          ""
        else
          "> 💭 " <> String.replace(thinking, "\n", "\n> ") <> "\n\n"
        end

      Kino.Frame.render(
        frame,
        Kino.Markdown.new("**🤖 봇**\n\n" <> thinking_block <> text)
      )
    end

    ensure_frame = fn frame ->
      case frame do
        nil ->
          f = Kino.Frame.new()
          Kino.Frame.append(chat_frame, f)
          f

        f ->
          f
      end
    end

    {:ok, agent_pid} = Pado.Agent.spawn(current_agent)

    collector =
      Task.async(fn ->
        agent_pid
        |> Pado.Stream.subscribe()
        |> Enum.reduce({nil, "", "", msgs0}, fn
          {:message_update, %{llm_event: {:thinking_delta, %{delta: d}}}},
          {frame, tbuf, buf, ms} ->
            frame = ensure_frame.(frame)
            new_tbuf = tbuf <> d
            render_reply.(frame, new_tbuf, buf)
            {frame, new_tbuf, buf, ms}

          {:message_update, %{llm_event: {:text_delta, %{delta: d}}}},
          {frame, tbuf, buf, ms} ->
            frame = ensure_frame.(frame)
            new_buf = buf <> d
            render_reply.(frame, tbuf, new_buf)
            {frame, tbuf, new_buf, ms}

          {:tool_execution_start, %{tool_name: name, args: args}}, {_frame, _tbuf, _buf, ms} ->
            block =
              "**🔧 " <> name <> "**\n\n" <>
                "```elixir\n" <> inspect(args, pretty: true) <> "\n```"

            Kino.Frame.append(chat_frame, Kino.Markdown.new(block))
            {nil, "", "", ms}

          {:tool_execution_end, %{tool_name: name, result: result, is_error: is_error}},
          {_frame, _tbuf, _buf, ms} ->
            emoji = if is_error, do: "⚠️", else: "📤"
            output = tool_result_text.(result)

            block =
              "**" <> emoji <> " " <> name <> " 결과**\n\n" <>
                "```\n" <> output <> "\n```"

            Kino.Frame.append(chat_frame, Kino.Markdown.new(block))
            {nil, "", "", ms}

          {:job_end, %{status: :aborted, turns: turns}}, {frame, tbuf, buf, ms} ->
            Kino.Frame.append(chat_frame, Kino.Markdown.new("**⛔ Aborted**"))
            new_ms = ms ++ Enum.flat_map(turns, &amp;Pado.Agent.Turn.as_llm_messages/1)
            {frame, tbuf, buf, new_ms}

          {:job_end, %{turns: turns}}, {frame, tbuf, buf, ms} ->
            new_ms = ms ++ Enum.flat_map(turns, &amp;Pado.Agent.Turn.as_llm_messages/1)
            {frame, tbuf, buf, new_ms}

          _other, acc ->
            acc
        end)
      end)

    :ok = wait_until_subscriber_count.(agent_pid, 1)
    callers = [self() | Process.get(:"$callers", [])]
    :ok = GenServer.call(agent_pid, {:start_job, self(), job, callers})
    Elixir.Agent.update(running_agent_pid, fn _ -> agent_pid end)

    {_frame, _tbuf, _buf, final_msgs} = Task.await(collector, :infinity)

    Elixir.Agent.update(state_pid, fn _ -> final_msgs end)

    Elixir.Agent.update(running_agent_pid, fn
      pid when pid == agent_pid -> nil
      pid -> pid
    end)
  end
end)

:ok

Load UI

Kino.Layout.grid([chat_frame, form, abort_button], boxed: true)