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.Agent.Session
alias Pado.Agent.Session.{JSONL, Store}
alias Pado.AgentConfig.LLM
alias Pado.LLM.Catalog.OpenAICodex
alias Pado.LLM.Credential.FileLoader
alias Pado.LLM.Message.{Assistant, ToolResult, 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: %LLM{
    provider: :openai_codex,
    credentials: creds,
    model: OpenAICodex.default()
  },
  harness: %Pado.AgentConfig.Harness{tools: [Pado.AgentConfig.Tools.Bash.tool(timeout: 10)]}
}

sessions_dir = Path.expand("~/.config/pado/sessions")
store = {JSONL, directory: sessions_dir}
:ok = File.mkdir_p(sessions_dir)

{:ok, summaries} = Store.list(store)

new_session_label = "새 세션"

session_options =
  [
    {:new, new_session_label}
    | Enum.map(summaries, fn summary ->
        label =
          summary.id <>
            " · " <>
            Calendar.strftime(summary.updated_at, "%Y-%m-%d %H:%M:%S")

        {summary.id, label}
      end)
  ]

session_input = Kino.Input.select("세션", session_options, default: :new)
session_title_input = Kino.Input.text("새 세션 ID", default: "")

session_form =
  Kino.Control.form(
    [session: session_input, new_id: session_title_input],
    submit: "세션 열기"
  )

session_frame = Kino.Frame.new()
chat_frame = Kino.Frame.new()

{:ok, session_pid} = Elixir.Agent.start_link(fn -> nil end)
{:ok, running_agent_pid} = Elixir.Agent.start_link(fn -> nil end)

open_session = fn selected, new_id ->
  case selected do
    :new ->
      now = DateTime.utc_now()

      id =
        new_id
        |> String.trim()
        |> case do
          "" -> "session-#{System.unique_integer([:positive])}"
          id -> id
        end

      session = %Session{id: id, created_at: now, updated_at: now}

      case Store.save(store, session) do
        :ok -> {:ok, session}
        {:error, reason} -> {:error, reason}
      end

    id when is_binary(id) ->
      Store.load(store, id)
  end
end

message_text = fn
  %User{content: content} when is_binary(content) ->
    content

  %User{content: parts} ->
    parts
    |> Enum.flat_map(fn
      {:text, text} -> [text]
      other -> [inspect(other)]
    end)
    |> Enum.join("\n")

  %Assistant{content: parts} ->
    parts
    |> Enum.flat_map(fn
      {:text, text} -> [text]
      {:thinking, text} -> ["> " <> String.replace(text, "\n", "\n> ")]
      {:tool_call, call} -> ["```elixir\n" <> inspect(call, pretty: true) <> "\n```"]
      other -> [inspect(other)]
    end)
    |> Enum.join("\n\n")

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

render_session = fn session ->
  Kino.Frame.clear(chat_frame)
  Kino.Frame.render(session_frame, Kino.Markdown.new("**세션** `" <> session.id <> "`"))

  session
  |> Session.to_llm_messages()
  |> Enum.each(fn
    %User{} = message ->
      Kino.Frame.append(chat_frame, Kino.Markdown.new("**나**\n\n" <> message_text.(message)))

    %Assistant{} = message ->
      text = message_text.(message)

      if text != "" do
        Kino.Frame.append(chat_frame, Kino.Markdown.new("**봇**\n\n" <> text))
      end

    %ToolResult{tool_name: name} = message ->
      Kino.Frame.append(
        chat_frame,
        Kino.Markdown.new("**" <> name <> " 결과**\n\n```\n" <> message_text.(message) <> "\n```")
      )
  end)
end

Kino.listen(session_form, fn %{data: %{session: selected, new_id: new_id}} ->
  case open_session.(selected, new_id) do
    {:ok, session} ->
      Elixir.Agent.update(session_pid, fn _ -> session end)
      render_session.(session)

    {:error, reason} ->
      Kino.Frame.render(session_frame, Kino.Markdown.new("**세션 오류**\n\n`" <> inspect(reason) <> "`"))
  end
end)

# 3. 대화 UI
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

tool_result_text = fn %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

Kino.listen(abort_button, fn _event ->
  case Elixir.Agent.get(running_agent_pid, &amp; &amp;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)
  session = Elixir.Agent.get(session_pid, &amp; &amp;1)

  cond do
    session == nil ->
      Kino.Frame.render(session_frame, Kino.Markdown.new("**세션을 먼저 열어 주세요.**"))

    text == "" ->
      :ok

    true ->
      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}}

      Kino.Frame.append(chat_frame, Kino.Markdown.new("**나**\n\n" <> text))

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

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

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

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

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

            {:tool_execution_end, %{tool_name: name, result: result, is_error: is_error}}, _acc ->
              label = if is_error, do: name <> " 오류", else: name <> " 결과"
              output = tool_result_text.(result)

              block =
                "**" <> label <> "**\n\n" <>
                  "```\n" <> output <> "\n```"

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

            {:job_end, %{status: :aborted}}, acc ->
              Kino.Frame.append(chat_frame, Kino.Markdown.new("**Aborted**"))
              acc

            {:job_end, %{session: updated_session}}, acc ->
              Elixir.Agent.update(session_pid, fn _ -> updated_session end)
              acc

            _other, acc ->
              acc
          end)
        end)

      :ok = wait_until_subscriber_count.(agent_pid, 1)
      :ok = Pado.Agent.start(agent_pid, text, job_id: "job-#{System.unique_integer([:positive])}")
      Elixir.Agent.update(running_agent_pid, fn _ -> agent_pid end)

      Task.await(collector, :infinity)

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

:ok

Load UI

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