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, & &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, & &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)