Mix.install([
{:kino, "~> 0.17.0"},
{:kino_progress_bar, github: "acalejos/kino_progress_bar"},
{:req_llm, "~> 1.0"}
])Don't worry about the warnings when opening the notebook: it is just telling that the original notebook had references to
LB_OPENROUTER_API_KEYwhich are not brought for security reasons. To see complete this notebook, you will need anOPENROUTER_API_KEYset inSecretsTab.
We want to allow a user pair program with an LLM, keeping a conversation about the notebook they are actively creating. This idea took inspiration from Jeremy Howard's Solve.it, an app and methodology designed to augment human capabilities with AI (and a refreshing alternative to the mind numbing nature of of vibe coding). The SolveIt app is Python-based, and I have been really enjoying the method, but wanted to bring it to Elixir where I also code with notebooks.
(The link above includes a 15% discount if you’d like to enroll in the SolveIt course.)
Prompt Buddy will be a Livebook Smart Cell that allow the user to give a prompt in the context of all the cells that precede it in the notebook.
To achieve this, we need to:
- Connect to an LLM, send a prompt, receive a response.
- ideally stream the response so the user don't have to wait until it;s complete to see something
- Create a Smart Cell UI that will allow the user to input the prompt
- Add the context (the source and outputs of the cells prior to the Smart Cell being inserted)
I already have some experience with ReqLLM so I believe that the first step will be quite easy. I have never created a Smart Cell before, but I saw one and other video that made me confident.
The problem is the 3rd step, passing the context, as it will require some introspection: accessing the current notebook state in real time to collect information about the cells that precede the current one.
To connect to an LLM with ReqLLM we need an API KEY. I decided to use OpenRouter and added the token in the Secrets menu in Livebook's navbar. It adds LB_OPENROUTER_API_KEY to the environment, which I then access:
ReqLLM.put_key(:openrouter_api_key, System.get_env("LB_OPENROUTER_API_KEY")):ok
Using ReqLLM is a breeze.
model = "openrouter:anthropic/claude-3-haiku"
messages = [
ReqLLM.Context.system("You are a sassy storyteller."),
ReqLLM.Context.user("Protagonist: Miss Plum"),
ReqLLM.Context.user("Location: In Paris"),
ReqLLM.Context.user("Tell me a 100 words story with the input I gave you")
]
{:ok, response} = ReqLLM.stream_text(model, messages)
ReqLLM.StreamResponse.tokens(response)
|> Stream.each(&IO.write/1)
|> Stream.run()Here is a 100-word story with the given input:
Miss Plum sauntered down the Champs-Élysées, her parasol twirling in the Parisian breeze. The city of lights shimmered around her, but the feisty American heiress had no time for mere sightseeing.
"Honestly, this place is far too stuffy for my liking," she huffed, her crimson lips pursed in a pout. "I simply must find a way to liven things up around here."
With a mischievous glint in her eye, Miss Plum headed for the Louvre, already formulating a plan to inject a little scandal into the staid Parisian social scene. After all, a girl's gotta do what a girl's gotta do.
:ok
ReqLLM has some nice functionalities like enforcing schemas and bringing usage and cost information.
usage = ReqLLM.StreamResponse.usage(response)%{
input: 42,
output: 187,
output_tokens: 187,
input_tokens: 42,
reasoning: 0,
total_tokens: 229,
cached_input: 0
}
I don't know why it did not show the cost this time, never mind!
The next step is to create a Smart Cell. I know the Smart Cell will need an UI, so I will start with that.
The idea here is just to understand the annatomy of a Smart Cell
defmodule Kino.Dumb do
use Kino.JS
use Kino.JS.Live # bi-directional communication with the frontend
use Kino.SmartCell, name: "Dumb"
# ------------------------------------------------------------
# 1. INITIALIZATION
# ------------------------------------------------------------
@impl true
# Called when the Smart Cell is first created.
# `attrs` contains saved attributes (if any) and `ctx` is the runtime context.
# This is helpful when the notebook is loaded from a file.
# Here we do nothing special—just return {:ok, ctx} to keep the context.
def init(attrs, ctx), do: {:ok, ctx}
# ------------------------------------------------------------
# 2. FRONTEND CONNECTION
# ------------------------------------------------------------
@impl true
# Called when the frontend (the JavaScript side) connects to the backend.
# We can send initial data to the frontend here.
# Returning {:ok, payload, ctx} sends `payload` to JS; we send an empty map.
def handle_connect(ctx),
do: {:ok, %{}, ctx}
# ------------------------------------------------------------
# 3. SERIALIZATION
# ------------------------------------------------------------
@impl true
# Defines which data from the context is stored as "attributes" of the cell
# when saving or exporting the notebook.
# Here we have nothing to persist, so we return an empty map.
def to_attrs(ctx), do: %{}
# ------------------------------------------------------------
# 4. CODE GENERATION
# ------------------------------------------------------------
@impl true
# Defines how the cell’s content is turned into Elixir source code.
# This is what will appear in the notebook when the Smart Cell "expands."
# Clicking the `< >` icon in the top of the cell.
def to_source(attrs) do
quote do
"Nothing to show"
end
# Kino helper that converts a quoted expression into a nicely formatted string.
|> Kino.SmartCell.quoted_to_string()
end
# ------------------------------------------------------------
# 5. FRONTEND ASSET (JavaScript)
# ------------------------------------------------------------
# Every Smart Cell can define a JS asset that runs in the browser.
# The string returned here is bundled and served to the frontend.
# It can use Livebook’s JS context API (ctx) to receive events or send messages.
asset "main.js" do
"""
// Entry point called when the JS component is initialized.
// `ctx` is the communication channel; `payload` is what we sent in handle_connect.
export function init(ctx, payload) {
// No frontend UI yet—this is intentionally "dumb".
}
"""
end
end
# ------------------------------------------------------------
# 6. REGISTRATION
# ------------------------------------------------------------
# Registers this Smart Cell so it appears in Livebook’s Smart Cell picker.
Kino.SmartCell.register(Kino.Dumb)warning: variable "attrs" is unused (if the variable is not meant to be used, prefix it with an underscore)
└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:15: Kino.Dumb.init/2
warning: variable "ctx" is unused (if the variable is not meant to be used, prefix it with an underscore)
└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:34: Kino.Dumb.to_attrs/1
warning: variable "attrs" is unused (if the variable is not meant to be used, prefix it with an underscore)
└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:43: Kino.Dumb.to_source/1
:ok
Click the toggle source icon:
"Nothing to show""Nothing to show"
The Kino library in Livebook allow to build simple UIs.
import Kino.Shorts
if key = System.get_env("LB_OPENROUTER_API_KEY") do
ReqLLM.put_key(:openrouter_api_key, key)
end
model = "openrouter:anthropic/claude-3-haiku"
# UI
form =
Kino.Control.form(
[prompt: Kino.Input.textarea("Book Title:", default: "")],
submit: "Submit"
)
form_frame = frame()
user_output = frame()
assistant_output = frame()
Kino.Frame.render(form_frame, form)
Kino.listen(form, fn event ->
# Hide the form after submit
Kino.Frame.render(form_frame, Kino.nothing())
book = String.trim(event.data.prompt || "Hamlet")
Kino.Frame.render(user_output, Kino.Markdown.new("**User**:\n\n" <> book))
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Assistant**:\n\n"))
messages = [
ReqLLM.Context.system("Summarize the given book in under 300 words, focusing on plot, themes, characters, and context."),
ReqLLM.Context.user("Book title: " <> book)
]
# Stream answer
Task.start(fn ->
case ReqLLM.stream_text(model, messages) do
{:ok, response} ->
# Render every N tokens to keep UI snappy (adjust N if you like)
n_every = 24
{final, _count} =
ReqLLM.StreamResponse.tokens(response)
|> Enum.reduce({"", 0}, fn token, {acc, n} ->
new = acc <> token
n2 = n + 1
if rem(n2, n_every) == 0 do
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Assistant**:\n\n" <> new))
end
{new, n2}
end)
# Final flush + checkmark
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Assistant**:\n\n" <> final <> " ✅")
)
{:error, err} ->
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Error**:\n\n```\n" <> inspect(err, pretty: true) <> "\n```")
)
end
end)
end)
grid([form_frame, user_output, assistant_output])Now we have everything to create a smart cell that summarizes books.
defmodule Kino.BookWorm do
use Kino.JS
use Kino.JS.Live
use Kino.SmartCell, name: "Book Worm"
@impl true
def init(attrs, ctx) do
{:ok,
assign(ctx,
model: attrs["model"] || "openrouter:anthropic/claude-3-haiku",
n_every: attrs["n_every"] || 24 # render every n_every streamed tokens
)}
end
@impl true
def handle_connect(ctx) do
{:ok,
%{
model: ctx.assigns[:model],
n_every: ctx.assigns[:n_every]
}, ctx}
end
@impl true
def to_attrs(ctx) do
%{
"model" => ctx.assigns[:model],
"n_every" => ctx.assigns[:n_every]
}
end
@impl true
def to_source(attrs) do
quote do
# ---------- Book Worm UI (auto-generated by Smart Cell) ----------
model = unquote(attrs["model"])
n_every = unquote(attrs["n_every"])
import Kino.Shorts
# --- UI skeleton
form =
Kino.Control.form(
[
book: Kino.Input.text("Book Title", default: "")
],
submit: "Summarize"
)
form_frame = frame()
user_output = frame()
assistant_output = frame()
Kino.Frame.render(form_frame, form)
Kino.listen(form, fn ev ->
# Optional: temporarily hide the form to avoid double submits
Kino.Frame.render(form_frame, Kino.nothing())
book_title = String.trim(ev.data.book || "")
Kino.Frame.render(
user_output,
Kino.Markdown.new("**Book**:\n\n" <> if(book_title == "", do: "_(empty)_", else: book_title))
)
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Summary**:\n\n"))
messages = [
ReqLLM.Context.system(
"Summarize the given book in under 500 words, focusing on plot, themes, characters, and context."
),
ReqLLM.Context.user("Book title: " <> (book_title == "" && "(empty)" || book_title))
]
# Stream answer
Task.start(fn ->
case ReqLLM.stream_text(model, messages) do
{:ok, response} ->
{final, _count} =
ReqLLM.StreamResponse.tokens(response)
|> Enum.reduce({"", 0}, fn token, {acc, n} ->
new_acc = acc <> token
n2 = n + 1
if rem(n2, n_every) == 0 do
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Summary**:\n\n" <> new_acc)
)
end
{new_acc, n2}
end)
# Final flush + checkmark
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Summary**:\n\n" <> final <> " ✅")
)
{:error, err} ->
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Error**:\n\n```\n" <> inspect(err, pretty: true) <> "\n```")
)
end
# Re-show the form after processing
Kino.Frame.render(form_frame, form)
end)
end)
grid([
form_frame,
user_output,
assistant_output
])
# ---------- /Book Worm UI ----------
end
|> Kino.SmartCell.quoted_to_string()
end
asset "main.js" do
"""
export function init(_ctx, _payload) {
// No client-side wiring needed yet
}
"""
end
end
Kino.SmartCell.register(Kino.BookWorm):ok
model = "openrouter:anthropic/claude-3-haiku"
n_every = 24
import Kino.Shorts
form =
Kino.Control.form([book: Kino.Input.text("Book Title", default: "Hamlet")],
submit: "Summarize"
)
form_frame = frame()
user_output = frame()
assistant_output = frame()
Kino.Frame.render(form_frame, form)
Kino.listen(form, fn ev ->
Kino.Frame.render(form_frame, Kino.nothing())
book_title = String.trim(ev.data.book || "")
Kino.Frame.render(
user_output,
Kino.Markdown.new(
"**Book**:\n\n" <>
if book_title == "" do
"_(empty)_"
else
book_title
end
)
)
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Summary**:\n\n"))
messages = [
ReqLLM.Context.system(
"Summarize the given book in under 500 words, focusing on plot, themes, characters, and context."
),
ReqLLM.Context.user("Book title: " <> ((book_title == "" && "(empty)") || book_title))
]
Task.start(fn ->
case ReqLLM.stream_text(model, messages) do
{:ok, response} ->
{final, _count} =
ReqLLM.StreamResponse.tokens(response)
|> Enum.reduce({"", 0}, fn token, {acc, n} ->
new_acc = acc <> token
n2 = n + 1
if rem(n2, n_every) == 0 do
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Summary**:\n\n" <> new_acc)
)
end
{new_acc, n2}
end)
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Summary**:\n\n" <> final <> " ✅")
)
{:error, err} ->
Kino.Frame.render(
assistant_output,
Kino.Markdown.new(
"**Error**:\n\n```\n" <> inspect(err, pretty: true) <> "\n```"
)
)
end
Kino.Frame.render(form_frame, form)
end)
end)
grid([form_frame, user_output, assistant_output])model = "openrouter:anthropic/claude-3-haiku"
n_every = 24
if key = System.get_env("LB_OPENROUTER_API_KEY") do
ReqLLM.put_key(:openrouter_api_key, key)
end
import Kino.Shorts
form =
Kino.Control.form([book: Kino.Input.text("Book title", default: "Romeo and Juliet")],
submit: "Summarize"
)
form_frame = frame()
user_output = frame()
assistant_output = frame()
Kino.Frame.render(form_frame, form)
Kino.listen(form, fn ev ->
Kino.Frame.render(form_frame, Kino.nothing())
book_title = String.trim(ev.data.book || "")
Kino.Frame.render(
user_output,
Kino.Markdown.new(
"**User**:\n\n" <>
if book_title == "" do
"_(empty)_"
else
book_title
end
)
)
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Assistant**:\n\n"))
messages = [
ReqLLM.Context.system(
"Summarize the given book in under 1000 words, focusing on plot, themes, characters, and context."
),
ReqLLM.Context.user("Book title: " <> ((book_title == "" && "(empty)") || book_title))
]
Task.start(fn ->
case ReqLLM.stream_text(model, messages) do
{:ok, response} ->
{final, _count} =
ReqLLM.StreamResponse.tokens(response)
|> Enum.reduce({"", 0}, fn token, {acc, n} ->
new_acc = acc <> token
n2 = n + 1
if rem(n2, n_every) == 0 do
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Assistant**:\n\n" <> new_acc)
)
end
{new_acc, n2}
end)
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Assistant**:\n\n" <> final <> " ✅")
)
{:error, err} ->
Kino.Frame.render(
assistant_output,
Kino.Markdown.new(
"**Error**:\n\n```\n" <> inspect(err, pretty: true) <> "\n```"
)
)
end
Kino.Frame.render(form_frame, form)
end)
end)
grid([form_frame, user_output, assistant_output])We already know how to connect to LLM and how to create a Smart Cell. The last step is the most difficult, do some reflection and find out the cells that precede the current one.
With some help from Hugo, I found out that I need to:
- Know the
current_cell_id, the id of the smart cell;
This is the easiest information to gather, Kino helps us:
Kino.Bridge.get_evaluation_file()
|> String.split("#cell:")
|> List.last()"yd76wwlitpax7qfd"
You can check that it is right by inspect the notebook HTML.
The session of me using this notebook is a node in the Elixir BEAM.
notebook_node = node():"[email protected]"
But we are interested in another node, the node of the Livebook app itself.
livebook_node =
node()
|> Atom.to_string()
|> String.replace(~r/--[^@]+@/, "@")
|> String.to_atom():"[email protected]"
As you have seen, every notebook node has the node address of its livebook.
sessions = :erpc.call(livebook_node, Livebook.Tracker, :list_sessions, [])[
%{
id: "356mdgtglekdibxpicxgpgd45ila3nwledjcxdlqcdwdyvmb",
pid: #PID<13576.992.0>,
file: %{
path: "/Users/fredguth/Downloads/internals.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.990.0>
},
mode: :default,
origin: {:file,
%{
path: "/Users/fredguth/Downloads/internals.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.990.0>
}},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 909553,
binary: 893608,
code: 15395623,
ets: 1380880,
processes: 17645336,
total: 52826313,
other: 16601313
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Downloads/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.990.0>
},
created_at: ~U[2025-11-04 20:58:46.352950Z],
notebook_name: "Livebook internals - fork"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nxi5bzp4k6qs5jsnkyz",
pid: #PID<13576.1462.0>,
file: nil,
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 737513,
binary: 970624,
code: 15489858,
ets: 1407616,
processes: 19909248,
total: 55176348,
other: 16661489
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_04/21_44_nkyz/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.1462.0>
},
created_at: ~U[2025-11-04 21:44:36.343424Z],
notebook_name: "Prompt SmartCell"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nx4kiuywub2hewtnxjm",
pid: #PID<13576.2286.0>,
file: nil,
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 581841,
binary: 877440,
code: 11077161,
ets: 1125512,
processes: 17764600,
total: 46770443,
other: 15343889
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/00_33_nxjm/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2286.0>
},
created_at: ~U[2025-11-05 00:33:06.043468Z],
notebook_name: "Build a chat app with Kino"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nx2bylyff3yxylz2tk2",
pid: #PID<13576.2329.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/elixir-circuits/circuits_sim/blob/6042c1e358ec0755ff8c65732517b5b59a4ad7cc/notebooks/circuitssim_in_kino.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1048841,
binary: 1263256,
code: 18817564,
ets: 17237592,
processes: 20249480,
total: 76247134,
other: 17630401
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/00_42_2tk2/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2329.0>
},
created_at: ~U[2025-11-05 00:42:49.201945Z],
notebook_name: "CircuitsSim in Kino Playground"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nuamos4c6fxz5jreyky",
pid: #PID<13576.2540.0>,
file: %{
path: "/Users/fredguth/code/learn/elixir/prompt_smartcell/explore.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4074.0>
},
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1138969,
binary: 1772336,
code: 21989894,
ets: 2217416,
processes: 23298336,
total: 73601328,
other: 23184377
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/code/learn/elixir/prompt_smartcell/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4074.0>
},
created_at: ~U[2025-11-05 01:31:17.570997Z],
notebook_name: "PromptBuddy SmartCell"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3ntnf32ev675bb2knvms",
pid: #PID<13576.2567.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/chgeuer/kino_websocket/blob/ee1ee53e8281270da1ff7cbeb3def42a5126f6cf/content/demo.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1057033,
binary: 1100464,
code: 19386182,
ets: 1775032,
processes: 19358312,
total: 60761296,
other: 18084273
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/01_33_nvms/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2567.0>
},
created_at: ~U[2025-11-05 01:33:01.103544Z],
notebook_name: "Websock Smartcell Demo"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nrcv4xky5fxuflohysx",
pid: #PID<13576.2596.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/w0rd-driven/kino_text_to_speech/blob/7e5c334566292e54411ef18053244fc9a6574ae1/notebooks/prototype.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 688353,
binary: 1313576,
code: 13851920,
ets: 16776048,
processes: 19404848,
total: 67983138,
other: 15948393
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/01_34_hysx/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2596.0>
},
created_at: ~U[2025-11-05 01:34:10.354936Z],
notebook_name: "Kino Text-to-Speech Prototype"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nu7yrm5qg5jpoyyfy7y",
pid: #PID<13576.2619.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/hectorperez/livebook-notebooks/blob/600aaa8d578c60807db10eef95eb8aa066792ab1/notebooks/livebook_intro_lightning_talk.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 991489,
binary: 1988472,
code: 19066527,
ets: 1848112,
processes: 22485992,
total: 64341577,
other: 17960985
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/01_35_fy7y/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2619.0>
},
created_at: ~U[2025-11-05 01:35:04.503861Z],
notebook_name: "Introduction to Livebook — Lightning Talk"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nvvimtnf2dseovlzx2x",
pid: #PID<13576.2690.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/sdball/github_graphql_smartcell/blob/9e226838ada363de011a2f143ff16b097a39e78a/guides/components.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1114385,
binary: 1353512,
code: 19283920,
ets: 1785064,
processes: 18872792,
total: 60898850,
other: 18489177
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/01_37_zx2x/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2690.0>
},
created_at: ~U[2025-11-05 01:37:58.801809Z],
notebook_name: "Components"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nt2bwrtaqifgrscdmuj",
pid: #PID<13576.2706.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/cocoa-xu/evision/blob/8179f92a3cf439628f38b80834d5f726ab52788d/examples/ml-decision_tree_and_random_forest.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 2466257,
binary: 1624480,
code: 43779597,
ets: 4333128,
processes: 22000160,
total: 101029663,
other: 26826041
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/01_38_dmuj/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2706.0>
},
created_at: ~U[2025-11-05 01:38:35.473194Z],
notebook_name: "Evision.ML Example - Decision Tree and Random Forest"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nutljdt4wdaginv5pi6",
pid: #PID<13576.2731.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/daneroo/elixir-garden/blob/873ac402bcb400350c64d8ac842a8d45f963b8a6/projects/machine-learning-in-elixir/code/StopReinventingTheWheel.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1368377,
binary: 2510888,
code: 30160711,
ets: 18415320,
processes: 32423032,
total: 108006473,
other: 23128145
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/01_41_5pi6/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2731.0>
},
created_at: ~U[2025-11-05 01:41:57.034057Z],
notebook_name: "Stop Reinventing the Wheel"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nxbzr3bvdkugnoolmzs",
pid: #PID<13576.2963.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/lostbean/req_cassette/blob/16046f39bd47b668b79a3bc1f0fc6dc688c68e5a/livebooks/req_llm.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1458505,
binary: 2105152,
code: 26310921,
ets: 18763256,
processes: 29466720,
total: 103162211,
other: 25057657
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/02_06_lmzs/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2963.0>
},
created_at: ~U[2025-11-05 02:06:56.427357Z],
notebook_name: "Req LLM with ReqCassette"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nvq3xtaheqwhuwqqcf7",
pid: #PID<13576.2991.0>,
file: nil,
mode: :default,
origin: {:url,
"https://github.com/agentjido/jido_workbench/blob/4b06a3b1a62cc37fff9ab19b55580f7807ea211f/priv/blog/2025/09-13-introducing-req_llm.livemd"},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1089809,
binary: 2185352,
code: 20519634,
ets: 2026696,
processes: 23334368,
total: 71982684,
other: 22826825
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/02_07_qcf7/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.2991.0>
},
created_at: ~U[2025-11-05 02:07:48.940393Z],
notebook_name: "Untitled notebook"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nuzhhvm4cmpqokh6cpm",
pid: #PID<13576.3712.0>,
file: nil,
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{runtime: nil, system: %{free: 3088859136, total: 17179869184}},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/03_40_6cpm/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.3712.0>
},
created_at: ~U[2025-11-05 03:40:03.765419Z],
notebook_name: "PromptBuddy SmartCell - fork"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt",
pid: #PID<13576.3740.0>,
file: %{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.3849.0>
},
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1098001,
binary: 3027560,
code: 21266575,
ets: 2129040,
processes: 33150768,
total: 84140081,
other: 23468137
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.3849.0>
},
created_at: ~U[2025-11-05 03:42:12.325660Z],
notebook_name: "Prompt Buddy"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nv3bubrt3czxt7hnmy7",
pid: #PID<13576.4279.0>,
file: nil,
mode: :default,
origin: {:file,
%{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4278.0>
}},
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1048841,
binary: 1547552,
code: 19933771,
ets: 1965152,
processes: 23973152,
total: 71016149,
other: 22547681
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/09_27_nmy7/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4279.0>
},
created_at: ~U[2025-11-05 09:27:37.811367Z],
notebook_name: "PromptBuddy SmartCell - fork - fork"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nsd2r2djvtw3uxhnmz5",
pid: #PID<13576.4479.0>,
file: nil,
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1212705,
binary: 1536968,
code: 21242971,
ets: 2036336,
processes: 19803640,
total: 65920965,
other: 20088345
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/11_05_nmz5/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4479.0>
},
created_at: ~U[2025-11-05 11:05:20.718947Z],
notebook_name: "Untitled notebook"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nqsyklcn3em6ny6lvfd",
pid: #PID<13576.4746.0>,
file: %{
path: "/Users/fredguth/Code/Learn/elixir/duckduck.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4754.0>
},
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 1048841,
binary: 1596744,
code: 19555684,
ets: 1909752,
processes: 22630264,
total: 69188222,
other: 22446937
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Code/Learn/elixir/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.4754.0>
},
created_at: ~U[2025-11-05 11:45:01.145637Z],
notebook_name: "Untitled notebook"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3ntnec3w4ux3waqgycju",
pid: #PID<13576.5298.0>,
file: nil,
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 491713,
binary: 770696,
code: 9045192,
ets: 769856,
processes: 15383824,
total: 40929162,
other: 14467881
},
system: %{free: 4703502336, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/13_02_ycju/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5298.0>
},
created_at: ~U[2025-11-05 13:02:38.344153Z],
notebook_name: "Untitled notebook"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nqxsxpj3mmclxasdlwq",
pid: #PID<13576.5355.0>,
file: %{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/explore.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5354.0>
},
mode: :default,
origin: {:file,
%{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/explore.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5354.0>
}},
__struct__: Livebook.Session,
memory_usage: %{runtime: nil, system: %{free: 5942083584, total: 17179869184}},
files_dir: %{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5354.0>
},
created_at: ~U[2025-11-05 13:08:28.597248Z],
notebook_name: "PromptBuddy SmartCell"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nr3jjon2mxxyy2lpv2j",
pid: #PID<13576.5371.0>,
file: %{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/nbs/explore.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5370.0>
},
mode: :default,
origin: {:file,
%{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/nbs/explore.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5370.0>
}},
__struct__: Livebook.Session,
memory_usage: %{runtime: nil, system: %{free: 6008733696, total: 17179869184}},
files_dir: %{
path: "/Users/fredguth/Code/Learn/elixir/prompt_smartcell/nbs/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5370.0>
},
created_at: ~U[2025-11-05 13:10:11.411621Z],
notebook_name: "Developing Prompt SmartCell"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nvox2vzxhutkdk3ivdi",
pid: #PID<13576.5387.0>,
file: nil,
mode: :default,
origin: nil,
__struct__: Livebook.Session,
memory_usage: %{
runtime: %{
atom: 737513,
binary: 1006952,
code: 15432957,
ets: 1390408,
processes: 23640792,
total: 58875335,
other: 16666713
},
system: %{free: 4765597696, total: 17179869184}
},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/13_12_ivdi/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5387.0>
},
created_at: ~U[2025-11-05 13:12:12.781574Z],
notebook_name: "Explore 2"
},
%{
id: "356mdgtglekdibxpicxgpgd45ila3nqgkbuo35ftu5fiufxo",
pid: #PID<13576.5496.0>,
file: nil,
mode: :default,
origin: {:file,
%{
path: "/Users/fredguth/Downloads/internals.livemd",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5495.0>
}},
__struct__: Livebook.Session,
memory_usage: %{runtime: nil, system: %{free: 5894963200, total: 17179869184}},
files_dir: %{
path: "/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/13_23_ufxo/files/",
__struct__: Livebook.FileSystem.File,
file_system_id: "local",
file_system_module: Livebook.FileSystem.Local,
origin_pid: #PID<13576.5496.0>
},
created_at: ~U[2025-11-05 13:23:39.637829Z],
notebook_name: "Livebook internals - fork - fork"
}
]
In a few moments you will see that we need to know our current session id to filter this map.
This information is in the URL, but there isn't a direct Elixir-side API that exposes the browser's document.baseURI.
But Let's remember we will create a SmartCell and smart cells have access to JS. Let's use it to get the session_id.
Let's modify the Dumb Smart Cell to get the session_id.
defmodule Kino.Cebola do
use Kino.JS
use Kino.JS.Live
use Kino.SmartCell, name: "Cebola"
def new(), do: Kino.JS.Live.new(__MODULE__, %{})
def get_session_id(kino), do: Kino.JS.Live.call(kino, :get_session_id)
def get_current_cell_id() do
Kino.Bridge.get_evaluation_file()
|> String.split("#cell:")
|> List.last()
end
@impl true
def init(_payload, ctx) do
{:ok, assign(ctx, session_id: nil)}
end
@impl true
def handle_connect(ctx),
do: {:ok, %{}, ctx}
@impl true
def to_attrs(_), do: %{}
@impl true
def to_source(_) do
end
@impl true
def handle_event("set_session_id", session_url, ctx) do
session_id =
case Regex.run(~r{/sessions/([^/]+)/}, session_url) do
[_, id] -> id
_ -> nil
end
{:noreply, assign(ctx, session_id: session_id)}
end
@impl true
def handle_call(:get_session_id, _from, ctx) do
{:reply, ctx.assigns.session_id, ctx}
end
asset "main.js" do
"""
export function init(ctx) {
// When the client connects, send the page baseURI so the backend can parse the session id.
ctx.pushEvent("set_session_id", document.baseURI);
}
"""
end
end
{:module, Kino.Cebola, <<70, 79, 82, 49, 0, 0, 30, ...>>, :ok}
cebola = Kino.Cebola.new()
# You must render it so the JS `init` runs and pushes the baseURI.
Kino.render(cebola)session_id = Kino.Cebola.get_session_id(cebola)
session_id"356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt"
session = Enum.find(sessions, &(&1.id == session_id))
session.pid#PID<13576.3740.0>
notebook = :erpc.call(livebook_node, Livebook.Session, :get_notebook, [session.pid])%{
name: "Prompt Buddy",
__struct__: Livebook.Notebook,
sections: [
%{
id: "dchgvqau4plh76yd",
name: "Goal",
__struct__: Livebook.Notebook.Section,
cells: [
%{
id: "ff5ouf2itzeuzaoi",
source: "We want to allow a user *pair program* with an LLM, keeping a conversation about the notebook they are actively creating. This idea took inspiration from [Jeremy Howard's](https://en.wikipedia.org/wiki/Jeremy_Howard_(entrepreneur)) [Solve.it](https://solve.it.com/?via_id=u0nb5yov), an app and methodology designed to augment human capabilities with AI (and a refreshing alternative to the mind numbing nature of of vibe coding). The SolveIt app is Python-based, and I have been really enjoying the method, but wanted to bring it to Elixir where I also code with notebooks. \n\n(*The [link](https://solve.it.com/?via_id=u0nb5yov) above includes a 15% discount if you’d like to enroll in the SolveIt course.*)\n\n`Prompt Buddy` will be a Livebook Smart Cell that allow the user to give a prompt in the context of all the cells that precede it in the notebook. \n\nTo achieve this, we need to:\n\n1. Connect to an LLM, send a prompt, receive a response.\n - ideally stream the response so the user don't have to wait until it;s complete to see something\n1. Create a Smart Cell UI that will allow the user to input the prompt\n1. Add the context (the source and outputs of the cells prior to the Smart Cell being inserted)\n\nI already have some experience with [ReqLLM](https://github.com/agentjido/req_llm) so I believe that the first step will be quite easy. I have never created a Smart Cell before, but I saw [one](https://www.youtube.com/watch?v=Yw_EqX_KAw4&t=59s) and [other](https://www.youtube.com/watch?v=2YVfHNFLROw&t=72s) video that made me confident. \n\nThe problem is the 3rd step, passing the context, as it will require some introspection: accessing the current notebook state in real time to collect information about the cells that precede the current one. ",
__struct__: Livebook.Notebook.Cell.Markdown
}
],
parent_id: nil
},
%{
id: "bmy7g4c3qfotx6go",
name: "ReqLLM",
__struct__: Livebook.Notebook.Section,
cells: [
%{
id: "26vceah6tgvqbukw",
source: "To connect to an LLM with ReqLLM we need an API KEY. I decided to use [OpenRouter](openrouter.ai) and added the token in the Secrets menu in Livebook's navbar. It adds `LB_OPENROUTER_API_KEY` to the environment, which I then access:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "7sqdcveiajogqpzi",
source: "ReqLLM.put_key(:openrouter_api_key, System.get_env(\"LB_OPENROUTER_API_KEY\"))",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2231, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}}],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "eddrynskxv7aa24r",
source: "Using ReqLLM is a breeze.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "7x767mxlhz4nomz4",
source: "\nmodel = \"openrouter:anthropic/claude-3-haiku\"\nmessages = [\n ReqLLM.Context.system(\"You are a sassy storyteller.\"),\n ReqLLM.Context.user(\"Protagonist: Miss Plum\"),\n ReqLLM.Context.user(\"Location: In Paris\"),\n ReqLLM.Context.user(\"Tell me a 100 words story with the input I gave you\")\n ]\n{:ok, response} = ReqLLM.stream_text(model, messages)\nReqLLM.StreamResponse.tokens(response)\n|> Stream.each(&IO.write/1)\n|> Stream.run()\n",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2249, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}},
{2232,
%{
type: :terminal_text,
text: "Here is a 100-word story with the given input:\n\nMiss Plum sauntered down the Champs-Élysées, her parasol twirling in the Parisian breeze. The city of lights shimmered around her, but the feisty American heiress had no time for mere sightseeing. \n\n\"Honestly, this place is far too stuffy for my liking,\" she huffed, her crimson lips pursed in a pout. \"I simply must find a way to liven things up around here.\"\n\nWith a mischievous glint in her eye, Miss Plum headed for the Louvre, already formulating a plan to inject a little scandal into the staid Parisian social scene. After all, a girl's gotta do what a girl's gotta do.",
chunk: true
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "zvfznjzxttjeymmi",
source: "ReqLLM has some nice functionalities like enforcing schemas and bringing usage and cost information. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "7pflqp6plsrknmrs",
source: "usage = ReqLLM.StreamResponse.usage(response)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2250,
%{
type: :terminal_text,
text: "%{\n \e[34minput:\e[0m \e[34m42\e[0m,\n \e[34moutput:\e[0m \e[34m187\e[0m,\n \e[34moutput_tokens:\e[0m \e[34m187\e[0m,\n \e[34minput_tokens:\e[0m \e[34m42\e[0m,\n \e[34mreasoning:\e[0m \e[34m0\e[0m,\n \e[34mtotal_tokens:\e[0m \e[34m229\e[0m,\n \e[34mcached_input:\e[0m \e[34m0\e[0m\n}",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "eob6jheusp3sn4g5",
source: "I don't know why it did not show the cost this time, never mind!",
__struct__: Livebook.Notebook.Cell.Markdown
}
],
parent_id: nil
},
%{
id: "c7xr7caqpljb3htt",
name: "Smart Cells",
__struct__: Livebook.Notebook.Section,
cells: [
%{
id: "6ecraejfebtevvk4",
source: "The next step is to create a `Smart Cell`. I know the Smart Cell will need an UI, so I will start with that. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "5bnqp37534aeize5",
source: "### A ~Dumb~ Simple Smart Cell First",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "xtpy3blyv24cvvv3",
source: "The idea here is just to understand the annatomy of a Smart Cell",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "wkahqpmfqajjbpsj",
source: "\ndefmodule Kino.Dumb do\n use Kino.JS\n use Kino.JS.Live # bi-directional communication with the frontend\n use Kino.SmartCell, name: \"Dumb\"\n\n # ------------------------------------------------------------\n # 1. INITIALIZATION\n # ------------------------------------------------------------\n @impl true\n # Called when the Smart Cell is first created.\n # `attrs` contains saved attributes (if any) and `ctx` is the runtime context.\n # This is helpful when the notebook is loaded from a file.\n # Here we do nothing special—just return {:ok, ctx} to keep the context.\n def init(attrs, ctx), do: {:ok, ctx}\n\n # ------------------------------------------------------------\n # 2. FRONTEND CONNECTION\n # ------------------------------------------------------------\n @impl true\n # Called when the frontend (the JavaScript side) connects to the backend.\n # We can send initial data to the frontend here.\n # Returning {:ok, payload, ctx} sends `payload` to JS; we send an empty map.\n def handle_connect(ctx),\n do: {:ok, %{}, ctx}\n\n # ------------------------------------------------------------\n # 3. SERIALIZATION\n # ------------------------------------------------------------\n @impl true\n # Defines which data from the context is stored as \"attributes\" of the cell\n # when saving or exporting the notebook.\n # Here we have nothing to persist, so we return an empty map.\n def to_attrs(ctx), do: %{}\n\n # ------------------------------------------------------------\n # 4. CODE GENERATION\n # ------------------------------------------------------------\n @impl true\n # Defines how the cell’s content is turned into Elixir source code.\n # This is what will appear in the notebook when the Smart Cell \"expands.\"\n # Clicking the `< >` icon in the top of the cell.\n def to_source(attrs) do\n quote do\n \"Nothing to show\"\n end\n # Kino helper that converts a quoted expression into a nicely formatted string.\n |> Kino.SmartCell.quoted_to_string()\n end\n\n # ------------------------------------------------------------\n # 5. FRONTEND ASSET (JavaScript)\n # ------------------------------------------------------------\n # Every Smart Cell can define a JS asset that runs in the browser.\n # The string returned here is bundled and served to the frontend.\n # It can use Livebook’s JS context API (ctx) to receive events or send messages.\n asset \"main.js\" do\n \"\"\"\n // Entry point called when the JS component is initialized.\n // `ctx` is the communication channel; `payload` is what we sent in handle_connect.\n export function init(ctx, payload) {\n // No frontend UI yet—this is intentionally \"dumb\".\n }\n \"\"\"\n end\nend\n\n# ------------------------------------------------------------\n# 6. REGISTRATION\n# ------------------------------------------------------------\n# Registers this Smart Cell so it appears in Livebook’s Smart Cell picker.\nKino.SmartCell.register(Kino.Dumb)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2252, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}},
{2251,
%{
type: :terminal_text,
text: "\e[33mwarning:\e[0m variable \"attrs\" is unused (if the variable is not meant to be used, prefix it with an underscore)\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:15: Kino.Dumb.init/2\n\n\e[33mwarning:\e[0m variable \"ctx\" is unused (if the variable is not meant to be used, prefix it with an underscore)\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:34: Kino.Dumb.to_attrs/1\n\n\e[33mwarning:\e[0m variable \"attrs\" is unused (if the variable is not meant to be used, prefix it with an underscore)\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:43: Kino.Dumb.to_source/1\n\n",
chunk: true
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "bmk2vapfsiobcipp",
source: "Click the `toggle source` icon:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "j4dldp26mnhfxlju",
chunks: nil,
editor: nil,
source: "\"Nothing to show\"",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.Dumb",
attrs: %{},
js_view: %{
pid: #PID<0.972.0>,
ref: "j4dldp26mnhfxlju",
assets: %{
hash: "hlcqf2ecowtm3jikg6o5afrqty",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_Dumb/hlcqf2ecowtm3jikg6o5afrqty.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [
{2253, %{type: :terminal_text, text: "\e[32m\"Nothing to show\"\e[0m", chunk: false}}
],
reevaluate_automatically: false
},
%{
id: "dczxm3zi5gy26kxk",
source: "### Book Summarizer UI",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "ub334sp3nykfx3nq",
source: "The Kino library in Livebook allow to build simple UIs.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "hkv65ekxlwt4pcyx",
source: "import Kino.Shorts\n\nif key = System.get_env(\"LB_OPENROUTER_API_KEY\") do\n ReqLLM.put_key(:openrouter_api_key, key)\nend\n\nmodel = \"openrouter:anthropic/claude-3-haiku\"\n\n# UI\nform =\n Kino.Control.form(\n [prompt: Kino.Input.textarea(\"Book Title:\", default: \"\")],\n submit: \"Submit\"\n )\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn event ->\n # Hide the form after submit\n Kino.Frame.render(form_frame, Kino.nothing())\n\n book = String.trim(event.data.prompt || \"Hamlet\")\n Kino.Frame.render(user_output, Kino.Markdown.new(\"**User**:\\n\\n\" <> book))\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\"Summarize the given book in under 300 words, focusing on plot, themes, characters, and context.\"),\n ReqLLM.Context.user(\"Book title: \" <> book)\n ]\n # Stream answer\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n # Render every N tokens to keep UI snappy (adjust N if you like)\n n_every = 24\n\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new = acc <> token\n n2 = n + 1\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> new))\n end\n {new, n2}\n end)\n\n # Final flush + checkmark\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\")\n )\n end\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])\n",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2258,
%{
type: :grid,
columns: 1,
outputs: [
{2255,
%{
type: :frame,
ref: "-576460752303415838",
outputs: [
{2283,
%{
type: :terminal_text,
text: "\e[34m:\"do not show this result in output\"\e[0m",
chunk: false
}}
],
placeholder: true
}},
{2256,
%{
type: :frame,
ref: "-576460752303415806",
outputs: [
{2284,
%{type: :markdown, text: "**User**:\n\nSkin in the game", chunk: false}}
],
placeholder: true
}},
{2257,
%{
type: :frame,
ref: "-576460752303415774",
outputs: [
{2292,
%{
type: :markdown,
text: "**Assistant**:\n\nHere is a 300-word summary of the book \"Skin in the Game\" by Nassim Nicholas Taleb:\n\n\"Skin in the Game\" is a thought-provoking exploration of the importance of having \"skin in the game\" - a real stake or personal risk in the outcomes of one's decisions and actions. Taleb argues that this principle is crucial for promoting fairness, accountability, and sound decision-making in various domains, from finance and business to politics and academia.\n\nThe central thesis is that people who don't have a real stake in the consequences of their choices should not be trusted to make important decisions. Taleb examines how a lack of skin in the game has led to significant problems, such as the 2008 financial crisis, where many Wall Street executives were insulated from the fallout of their risky bets.\n\nTaleb delves into the historical origins of the skin in the game concept, tracing it back to ancient traditions and ethical frameworks. He also explores how the principle applies to modern life, from the importance of craftspeople and artisans having a personal investment in their work, to the dangers of experts and policymakers who are removed from the real-world impact of their recommendations.\n\nThroughout the book, Taleb's writing is characterized by his trademark blend of erudition, wit, and iconoclastic style. He challenges readers to question conventional wisdom and to demand more accountability from those in positions of power and influence.\n\n\"Skin in the Game\" ultimately argues that embracing the skin in the game principle is essential for creating a more just, robust, and antifragile society. It's a thought-provoking and engaging read that will likely inspire readers to reevaluate their own roles and responsibilities in the world. ✅",
chunk: false
}}
],
placeholder: true
}}
],
boxed: false,
gap: 8,
max_height: nil
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "pb3ocn3nne7uylem",
source: "### Book Worm Smart Cell",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "kq7tghzjzhitkkl6",
source: "Now we have everything to create a smart cell that summarizes books.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "2zw7mp536rf5iudk",
source: "defmodule Kino.BookWorm do\n use Kino.JS\n use Kino.JS.Live\n use Kino.SmartCell, name: \"Book Worm\"\n\n @impl true\n def init(attrs, ctx) do\n {:ok,\n assign(ctx,\n model: attrs[\"model\"] || \"openrouter:anthropic/claude-3-haiku\",\n n_every: attrs[\"n_every\"] || 24 # render every n_every streamed tokens\n )}\n end\n\n @impl true\n def handle_connect(ctx) do\n {:ok,\n %{\n model: ctx.assigns[:model],\n n_every: ctx.assigns[:n_every]\n }, ctx}\n end\n\n @impl true\n def to_attrs(ctx) do\n %{\n \"model\" => ctx.assigns[:model],\n \"n_every\" => ctx.assigns[:n_every]\n }\n end\n\n @impl true\n def to_source(attrs) do\n quote do\n # ---------- Book Worm UI (auto-generated by Smart Cell) ----------\n model = unquote(attrs[\"model\"])\n n_every = unquote(attrs[\"n_every\"])\n\n import Kino.Shorts\n\n # --- UI skeleton\n form =\n Kino.Control.form(\n [\n book: Kino.Input.text(\"Book Title\", default: \"\")\n ],\n submit: \"Summarize\"\n )\n\n form_frame = frame()\n user_output = frame()\n assistant_output = frame()\n\n Kino.Frame.render(form_frame, form)\n\n Kino.listen(form, fn ev ->\n # Optional: temporarily hide the form to avoid double submits\n Kino.Frame.render(form_frame, Kino.nothing())\n\n book_title = String.trim(ev.data.book || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\"**Book**:\\n\\n\" <> if(book_title == \"\", do: \"_(empty)_\", else: book_title))\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Summary**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\n \"Summarize the given book in under 500 words, focusing on plot, themes, characters, and context.\"\n ),\n ReqLLM.Context.user(\"Book title: \" <> (book_title == \"\" && \"(empty)\" || book_title))\n ]\n\n # Stream answer\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new_acc = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> new_acc)\n )\n end\n\n {new_acc, n2}\n end)\n\n # Final flush + checkmark\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\")\n )\n end\n\n # Re-show the form after processing\n Kino.Frame.render(form_frame, form)\n end)\n end)\n\n grid([\n form_frame,\n user_output,\n assistant_output\n ])\n # ---------- /Book Worm UI ----------\n end\n |> Kino.SmartCell.quoted_to_string()\n end\n\n asset \"main.js\" do\n \"\"\"\n export function init(_ctx, _payload) {\n // No client-side wiring needed yet\n }\n \"\"\"\n end\nend\n\nKino.SmartCell.register(Kino.BookWorm)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2259, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}}],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "kghc2s4ywzhltphe",
chunks: nil,
editor: nil,
source: "model = \"openrouter:anthropic/claude-3-haiku\"\nn_every = 24\nimport Kino.Shorts\n\nform =\n Kino.Control.form([book: Kino.Input.text(\"Book Title\", default: \"Hamlet\")],\n submit: \"Summarize\"\n )\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn ev ->\n Kino.Frame.render(form_frame, Kino.nothing())\n book_title = String.trim(ev.data.book || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\n \"**Book**:\\n\\n\" <>\n if book_title == \"\" do\n \"_(empty)_\"\n else\n book_title\n end\n )\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Summary**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\n \"Summarize the given book in under 500 words, focusing on plot, themes, characters, and context.\"\n ),\n ReqLLM.Context.user(\"Book title: \" <> ((book_title == \"\" && \"(empty)\") || book_title))\n ]\n\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new_acc = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> new_acc)\n )\n end\n\n {new_acc, n2}\n end)\n\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\n \"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\"\n )\n )\n end\n\n Kino.Frame.render(form_frame, form)\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.BookWorm",
attrs: %{},
js_view: %{
pid: #PID<0.1402.0>,
ref: "kghc2s4ywzhltphe",
assets: %{
hash: "c5fsrrj7l3ud5kfwhpwvxt2t64",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_BookWorm/c5fsrrj7l3ud5kfwhpwvxt2t64.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [
{2264,
%{
type: :grid,
columns: 1,
outputs: [
{2261,
%{
type: :frame,
ref: "-576460752303419324",
outputs: [
{2260,
%{
type: :control,
ref: "vcmvav2i5msp2ot5adp6vp4vbipaopfe",
attrs: %{...},
...
}}
],
placeholder: true
}},
{2262, %{type: :frame, ref: "-576460752303419292", outputs: [], placeholder: true}},
{2263, %{type: :frame, ref: "-576460752303419260", outputs: [], placeholder: true}}
],
boxed: false,
gap: 8,
max_height: nil
}}
],
reevaluate_automatically: false
},
%{
id: "pkwbcdh2zwixvnet",
chunks: nil,
editor: nil,
source: "model = \"openrouter:anthropic/claude-3-haiku\"\nn_every = 24\n\nif key = System.get_env(\"LB_OPENROUTER_API_KEY\") do\n ReqLLM.put_key(:openrouter_api_key, key)\nend\n\nimport Kino.Shorts\n\nform =\n Kino.Control.form([book: Kino.Input.text(\"Book title\", default: \"Romeo and Juliet\")],\n submit: \"Summarize\"\n )\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn ev ->\n Kino.Frame.render(form_frame, Kino.nothing())\n book_title = String.trim(ev.data.book || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\n \"**User**:\\n\\n\" <>\n if book_title == \"\" do\n \"_(empty)_\"\n else\n book_title\n end\n )\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\n \"Summarize the given book in under 1000 words, focusing on plot, themes, characters, and context.\"\n ),\n ReqLLM.Context.user(\"Book title: \" <> ((book_title == \"\" && \"(empty)\") || book_title))\n ]\n\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new_acc = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> new_acc)\n )\n end\n\n {new_acc, n2}\n end)\n\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\n \"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\"\n )\n )\n end\n\n Kino.Frame.render(form_frame, form)\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.BookWorm",
attrs: %{},
js_view: %{
pid: #PID<0.1072.0>,
ref: "pkwbcdh2zwixvnet",
assets: %{
hash: "c5fsrrj7l3ud5kfwhpwvxt2t64",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_BookWorm/c5fsrrj7l3ud5kfwhpwvxt2t64.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [
{2269,
%{
type: :grid,
columns: 1,
outputs: [
{2266,
%{
type: :frame,
ref: "-576460752303412255",
outputs: [
{2308, %{type: :control, ref: "lpz7dz757xbfwbcxdj3n2gqfvybj5eqm", ...}}
],
placeholder: true
}},
{2267,
%{
type: :frame,
ref: "-576460752303412223",
outputs: [{2294, %{type: :markdown, ...}}],
placeholder: true
}},
{2268,
%{
type: :frame,
ref: "-576460752303412191",
outputs: [{2307, %{...}}],
placeholder: true
}}
],
boxed: false,
gap: 8,
max_height: nil
}}
],
reevaluate_automatically: false
}
],
parent_id: nil
},
%{
id: "kh6x7rdjzsqrynxt",
name: "Introspection",
__struct__: Livebook.Notebook.Section,
cells: [
%{
id: "6xizpfzuixvjtb2z",
source: "We already know how to connect to LLM and how to create a Smart Cell. The last step is the most difficult, do some `reflection` and find out the cells that precede the current one. \n\nWith some help from [Hugo](https://gist.github.com/hugobarauna), I found out that I need to:\n- Know the `current_cell_id`, the id of the smart cell;",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "ehjb55g2cy5h3seg",
source: "#### current_cell_id\nThis is the easiest information to gather, Kino helps us:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "yd76wwlitpax7qfd",
source: "Kino.Bridge.get_evaluation_file()\n |> String.split(\"#cell:\")\n |> List.last()",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2270, %{type: :terminal_text, text: "\e[32m\"yd76wwlitpax7qfd\"\e[0m", chunk: false}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "rix2wxxkw27nuzrr",
source: "You can check that it is right by inspect the notebook HTML. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "re3p5baih7khygv5",
source: "#### livebook node",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "sxkqwayjshraye2o",
source: "The session of me using this notebook is a node in the Elixir BEAM. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "jyhsqdrszxxkmuvt",
source: "notebook_node = node()",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2271,
%{
type: :terminal_text,
text: "\e[34m:\"[email protected]\"\e[0m",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "qiauc5dflu75c47m",
source: "But we are interested in another node, the node of the Livebook app itself. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "tmkhcjdgqhaga5xb",
source: "livebook_node = \n node()\n |> Atom.to_string()\n |> String.replace(~r/--[^@]+@/, \"@\")\n |> String.to_atom()",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2272,
%{
type: :terminal_text,
text: "\e[34m:\"[email protected]\"\e[0m",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "z34qlc6dql425fcz",
source: "As you have seen, every notebook node has the node address of its livebook.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "akxct6dnc2s7dxgy",
source: "#### sessions",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "cwtt66yb5w5jndh4",
source: "sessions = :erpc.call(livebook_node, Livebook.Tracker, :list_sessions, [])",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2273,
%{
type: :terminal_text,
text: "[\n %{\n \e[34mid:\e[0m \e[32m\"356mdgtglekdibxpicxgpgd45ila3nwledjcxdlqcdwdyvmb\"\e[0m,\n \e[34mpid:\e[0m #PID<13576.992.0>,\n \e[34mfile:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Downloads/internals.livemd\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.990.0>\n },\n \e[34mmode:\e[0m \e[34m:default\e[0m,\n \e[34morigin:\e[0m {\e[34m:file\e[0m,\n %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Downloads/internals.livemd\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.990.0>\n }},\n \e[34m__struct__:\e[0m \e[34mLivebook.Session\e[0m,\n \e[34mmemory_usage:\e[0m %{\n \e[34mruntime:\e[0m %{\n \e[34matom:\e[0m \e[34m909553\e[0m,\n \e[34mbinary:\e[0m \e[34m893608\e[0m,\n \e[34mcode:\e[0m \e[34m15395623\e[0m,\n \e[34mets:\e[0m \e[34m1380880\e[0m,\n \e[34mprocesses:\e[0m \e[34m17645336\e[0m,\n \e[34mtotal:\e[0m \e[34m52826313\e[0m,\n \e[34mother:\e[0m \e[34m16601313\e[0m\n },\n \e[34msystem:\e[0m %{\e[34mfree:\e[0m \e[34m4703502336\e[0m, \e[34mtotal:\e[0m \e[34m17179869184\e[0m}\n },\n \e[34mfiles_dir:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Downloads/files/\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.990.0>\n },\n \e[34mcreated_at:\e[0m ~U[2025-11-04 20:58:46.352950Z],\n \e[34mnotebook_name:\e[0m \e[32m\"Livebook internals - fork\"\e[0m\n },\n %{\n \e[34mid:\e[0m \e[32m\"356mdgtglekdibxpicxgpgd45ila3nxi5bzp4k6qs5jsnkyz\"\e[0m,\n \e[34mpid:\e[0m #PID<13576.1462.0>,\n \e[34mfile:\e[0m \e[35mnil\e[0m,\n \e[34mmode:\e[0m \e[34m:default\e[0m,\n \e[34morigin:\e[0m \e[35mnil\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.Session\e[0m,\n \e[34mmemory_usage:\e[0m %{\n \e[34mruntime:\e[0m %{\n \e[34matom:\e[0m \e[34m737513\e[0m,\n \e[34mbinary:\e[0m \e[34m970624\e[0m,\n \e[34mcode:\e[0m \e[34m15489858\e[0m,\n \e[34mets:\e[0m \e[34m1407616\e[0m,\n \e[34mprocesses:\e[0m \e[34m19909248\e[0m,\n \e[34mtotal:\e[0m \e[34m55176348\e[0m,\n \e[34mother:\e[0m \e[34m16661489\e[0m\n },\n \e[34msystem:\e[0m %{\e[34mfree:\e[0m \e[34m4765597696\e[0m, \e[34mtotal:\e[0m \e[34m17179869184\e[0m}\n },\n \e[34mfiles_dir:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_04/21_44_nkyz/files/\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.1462.0>\n },\n \e[34mcreated_at:\e[0m ~U[2025-11-04 21:44:36.343424Z],\n \e[34mnotebook_name:\e[0m \e[32m\"Prompt SmartCell\"\e[0m\n },\n %{\n \e[34mid:\e[0m \e[32m\"356mdgtglekdibxpicxgpgd45ila3nx4kiuywub2hewtnxjm\"\e[0m,\n \e[34mpid:\e[0m #PID<13576.2286.0>,\n \e[34mfile:\e[0m \e[35mnil\e[0m,\n \e[34mmode:\e[0m \e[34m:default\e[0m,\n \e[34morigin:\e[0m \e[35mnil\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.Session\e[0m,\n \e[34mmemory_usage:\e[0m %{\n \e[34mruntime:\e[0m %{\n \e[34matom:\e[0m \e[34m581841\e[0m,\n \e[34mbinary:\e[0m \e[34m877440\e[0m,\n \e[34mcode:\e[0m \e[34m11077161\e[0m,\n \e[34mets:\e[0m \e[34m1125512\e[0m,\n \e[34mprocesses:\e[0m \e[34m17764600\e[0m,\n \e[34mtotal:\e[0m \e[34m46770443\e[0m,\n \e[34mother:\e[0m \e[34m15343889\e[0m\n },\n \e[34msystem:\e[0m %{\e[34mfree:\e[0m \e[34m4765597696\e[0m, \e[34mtotal:\e[0m \e[34m17179869184\e[0m}\n },\n \e[34mfiles_dir:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/00_33_nxjm/files/\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n " <> ...,
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "crl6gho4gt2xoer5",
source: "In a few moments you will see that we need to know our current session id to filter this map.\nThis information is in the URL, but there isn't a direct Elixir-side API that exposes the browser's `document.baseURI`. \n\nBut Let's remember we will create a SmartCell and smart cells have access to JS. Let's use it to get the session_id.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "lhamwfpjmlcynoiv",
source: "#### session_id",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "mqd4xwmbzbcmqzkf",
source: "Let's modify the Dumb Smart Cell to get the `session_id`.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "rgefdrrqrwyidu4w",
source: "\ndefmodule Kino.Cebola do\n use Kino.JS\n use Kino.JS.Live \n use Kino.SmartCell, name: \"Cebola\"\n\n def new(), do: Kino.JS.Live.new(__MODULE__, %{})\n def get_session_id(kino), do: Kino.JS.Live.call(kino, :get_session_id)\n def get_current_cell_id() do\n Kino.Bridge.get_evaluation_file()\n |> String.split(\"#cell:\")\n |> List.last()\n end\n @impl true\n def init(_payload, ctx) do\n {:ok, assign(ctx, session_id: nil)}\n end\n \n @impl true\n def handle_connect(ctx),\n do: {:ok, %{}, ctx}\n\n @impl true\n def to_attrs(_), do: %{}\n \n @impl true\n def to_source(_) do\n end\n\n @impl true\n def handle_event(\"set_session_id\", session_url, ctx) do\n session_id =\n case Regex.run(~r{/sessions/([^/]+)/}, session_url) do\n [_, id] -> id\n _ -> nil\n end\n\n {:noreply, assign(ctx, session_id: session_id)}\n end\n\n @impl true\n def handle_call(:get_session_id, _from, ctx) do\n {:reply, ctx.assigns.session_id, ctx}\n end\n \n asset \"main.js\" do\n \"\"\"\n export function init(ctx) {\n // When the client connects, send the page baseURI so the backend can parse the session id.\n ctx.pushEvent(\"set_session_id\", document.baseURI);\n }\n \"\"\"\n end\nend\n\n",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2274,
%{
type: :terminal_text,
text: "{\e[34m:module\e[0m, \e[34mKino.Cebola\e[0m, <<\e[34m70\e[0m, \e[34m79\e[0m, \e[34m82\e[0m, \e[34m49\e[0m, \e[34m0\e[0m, \e[34m0\e[0m, \e[34m30\e[0m, ...>>, \e[34m:ok\e[0m}",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "vwh6rdjncqorqr3n",
source: "cebola = Kino.Cebola.new()\n\n# You must render it so the JS `init` runs and pushes the baseURI.\nKino.render(cebola)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2310,
%{
type: :js,
export: false,
js_view: %{
pid: #PID<0.1888.0>,
ref: "hczqnd63p4yb2rmhyk2rdl6bwrswhbee",
assets: %{
hash: "hve47ucelftsaqcyjtphutnuma",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_Cebola/hve47ucelftsaqcyjtphutnuma.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
}
}},
{2309,
%{
type: :js,
export: false,
js_view: %{
pid: #PID<0.1888.0>,
ref: "hczqnd63p4yb2rmhyk2rdl6bwrswhbee",
assets: %{
hash: "hve47ucelftsaqcyjtphutnuma",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_Cebola/hve47ucelftsaqcyjtphutnuma.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
}
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "t2mo5sjxgnzsjn2u",
source: "session_id = Kino.Cebola.get_session_id(cebola)\nsession_id",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2311,
%{
type: :terminal_text,
text: "\e[32m\"356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt\"\e[0m",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "22u3ihjzowyoej7t",
source: "#### session.pid",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "2zrkhmqhp7nchf5j",
source: "session = Enum.find(sessions, &(&1.id == session_id))\nsession.pid",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2312, %{type: :terminal_text, text: "#PID<13576.3740.0>", chunk: false}}],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "nlnsdq3cglgi3kxp",
source: "#### notebook",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "gxmxrkq3fstyk2lw",
source: "notebook = :erpc.call(livebook_node, Livebook.Session, :get_notebook, [session.pid])",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "zs2d6acskfyt3smk",
source: "#### precedent_cells\nNow we have what we need to get the context:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "trlnloy2dms2zxzf",
source: "all = Enum.flat_map(notebook.sections, & &1.cells)\nidx = Enum.find_index(all, &(&1.id == Kino.Cebola.get_current_cell_id()))\ncells = Enum.take(all, idx+1)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2282,
%{
message: "\e[31m** (CompileError) cannot compile cell (errors have been logged)\e[0m",
type: :error,
context: nil
}},
{2281,
%{
type: :terminal_text,
text: "\e[31merror:\e[0m undefined variable \"notebook\"\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:trlnloy2dms2zxzf:1\n\n",
chunk: true
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{id: "bgqiuqpvhw32ee7d", source: "Phew!\n", __struct__: Livebook.Notebook.Cell.Markdown}
],
parent_id: nil
},
%{
id: "xe7q3x56s3zz5ymu",
name: "Wrapping Up: Hello Buddy!",
__struct__: Livebook.Notebook.Section,
cells: [
%{
id: "e4v6q5ywbucmm5dd",
source: "defmodule Kino.PromptBuddy do\n use Kino.JS\n use Kino.JS.Live\n use Kino.SmartCell, name: \"Prompt Buddy\"\n\n# -- Public API --------------------------------------------------------------\n\n def new(), do: Kino.JS.Live.new(__MODULE__, %{})\n\n def get_session_id(kino), do: Kino.JS.Live.call(kino, :get_session_id)\n\n def get_current_cell_id() do\n Kino.Bridge.get_evaluation_file()\n |> String.split(\"#cell:\")\n |> List.last()\n end\n\n def get_notebook(session_id) do\n node_norm =\n node()\n |> Atom.to_string()\n |> String.replace(~r/--[^@]+@/, \"@\")\n |> String.to_atom()\n\n Node.set_cookie(node_norm, Node.get_cookie())\n sessions = :erpc.call(node_norm, Livebook.Tracker, :list_sessions, [])\n case Enum.find(sessions, &(&1.id == session_id)) do\n nil -> {:error, :session_not_found}\n s -> {:ok, :erpc.call(node_norm, Livebook.Session, :get_notebook, [s.pid])}\n end\n end\n\n defp cells_until(notebook, current_cell_id) do\n all = Enum.flat_map(notebook.sections, & &1.cells)\n idx = Enum.find_index(all, &(&1.id == current_cell_id)) || length(all) - 1\n {:ok, Enum.take(all, idx + 1)}\n end\n \n def build_messages(session_id, current_cell_id) do\n with true <- is_binary(session_id),\n true <- is_binary(current_cell_id),\n {:ok, notebook} <- get_notebook(session_id),\n {:ok, cells} <- cells_until(notebook, current_cell_id) do\n cells\n |> Enum.map(&String.trim(&1.source || \"\"))\n |> Enum.reject(&(&1 == \"\"))\n |> Enum.with_index()\n |> Enum.map(fn\n {text, 0} -> ReqLLM.Context.system(text)\n {text, _} -> ReqLLM.Context.user(text)\n end)\n else\n _ -> []\n end\n end\n\n \n @impl true\n def init(attrs, ctx) do\n {:ok,\n assign(ctx,\n session_id: attrs[\"session_id\"],\n model: attrs[\"model\"] || \"openrouter:anthropic/claude-sonnet-4.5\",\n n_every: attrs[\"n_every\"] || 24\n )}\n end\n\n @impl true\n def handle_connect(ctx) do\n {:ok,\n %{\n session_id: ctx.assigns[:session_id],\n model: ctx.assigns[:model],\n n_every: ctx.assigns[:n_every]\n }, ctx}\n end\n\n @impl true\n def handle_event(\"set_session_id\", session_url, ctx) do\n session_id =\n case Regex.run(~r{/sessions/([^/]+)/}, session_url) do\n [_, id] -> id\n _ -> nil\n end\n\n {:noreply, assign(ctx, session_id: session_id)}\n end\n\n @impl true\n def to_attrs(ctx) do\n %{\n \"session_id\" => ctx.assigns[:session_id],\n \"model\" => ctx.assigns[:model],\n \"n_every\" => ctx.assigns[:n_every]\n }\n end\n\n @impl true\n def to_source(attrs) do\n quote do\n # ---------- PromptBuddy UI (auto-generated by SmartCell) ----------\n model = unquote(attrs[\"model\"])\n n_every = unquote(attrs[\"n_every\"])\n session_id = unquote(attrs[\"session_id\"]) \n current_cell_id = Kino.PromptBuddy.get_current_cell_id()\n\n\n import Kino.Shorts\n\n # --- UI skeleton\n form =\n Kino.Control.form(\n [\n prompt: Kino.Input.textarea(\"Prompt\", default: \"\")\n ],\n submit: \"Send\"\n )\n\n form_frame = frame()\n user_output = frame()\n assistant_output = frame()\n\n Kino.Frame.render(form_frame, form)\n\n Kino.listen(form, fn ev ->\n # Hide form to discourage double-submit; re-render at end if you prefer\n Kino.Frame.render(form_frame, Kino.nothing())\n\n user_text = String.trim(ev.data.prompt || \"\")\n Kino.Frame.render(user_output, Kino.Markdown.new(\"**User**:\\n\\n\" <> (user_text == \"\" && \"_(empty)_\" || user_text)))\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n # 1) Build context from previous cells\n base_ctx =\n case {session_id, current_cell_id} do\n {sid, cid} when is_binary(sid) and is_binary(cid) ->\n Kino.PromptBuddy.build_messages(sid, cid)\n _ ->\n []\n end\n\n messages = base_ctx ++ [ReqLLM.Context.user(user_text)]\n\n # Stream\n Task.start(fn ->\n ca" <> ...,
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "2japeldx77hanrqe",
chunks: nil,
editor: nil,
source: "model = \"openrouter:anthropic/claude-3-haiku\"\nn_every = 24\nsession_id = \"356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt\"\ncurrent_cell_id = Kino.PromptBuddy.get_current_cell_id()\nimport Kino.Shorts\n\nform =\n Kino.Control.form([prompt: Kino.Input.textarea(\"Prompt\", default: \"\")], submit: \"Send\")\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn ev ->\n Kino.Frame.render(form_frame, Kino.nothing())\n user_text = String.trim(ev.data.prompt || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\"**User**:\\n\\n\" <> ((user_text == \"\" && \"_(empty)_\") || user_text))\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n base_ctx =\n case {session_id, current_cell_id} do\n {sid, cid} when is_binary(sid) and is_binary(cid) ->\n Kino.PromptBuddy.build_messages(sid, cid)\n\n _ ->\n []\n end\n\n messages = base_ctx ++ [ReqLLM.Context.user(user_text)]\n\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> new)\n )\n end\n\n {new, n2}\n end)\n\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\n \"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\"\n )\n )\n end\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.PromptBuddy",
attrs: %{
"model" => "openrouter:anthropic/claude-3-haiku",
"n_every" => 24,
"session_id" => "356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt"
},
js_view: %{
pid: #PID<0.1871.0>,
ref: "2japeldx77hanrqe",
assets: %{
hash: "qhxibsxtdlpfiza26ejkcmyaxq",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_PromptBuddy/qhxibsxtdlpfiza26ejkcmyaxq.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [],
reevaluate_automatically: false
},
%{
id: "wjtqfwnrqacgaune",
chunks: nil,
editor: nil,
source: "model = \"openrouter:anthropic/claude-3-haiku\"\nn_every = 24\nsession_id = \"356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt\"\ncurrent_cell_id = Kino.PromptBuddy.get_current_cell_id()\nimport Kino.Shorts\n\nform =\n Kino.Control.form([prompt: Kino.Input.textarea(\"Prompt\", default: \"\")], submit: \"Send\")\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn ev ->\n Kino.Frame.render(form_frame, Kino.nothing())\n user_text = String.trim(ev.data.prompt || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\"**User**:\\n\\n\" <> ((user_text == \"\" && \"_(empty)_\") || user_text))\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n base_ctx =\n case {session_id, current_cell_id} do\n {sid, cid} when is_binary(sid) and is_binary(cid) ->\n Kino.PromptBuddy.build_messages(sid, cid)\n\n _ ->\n []\n end\n\n messages = base_ctx ++ [ReqLLM.Context.user(user_text)]\n\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> new)\n )\n end\n\n {new, n2}\n end)\n\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\n \"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\"\n )\n )\n end\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.PromptBuddy",
attrs: %{
"model" => "openrouter:anthropic/claude-3-haiku",
"n_every" => 24,
"session_id" => "356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt"
},
js_view: %{
pid: #PID<0.1796.0>,
ref: "wjtqfwnrqacgaune",
assets: %{
hash: "qhxibsxtdlpfiza26ejkcmyaxq",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_PromptBuddy/qhxibsxtdlpfiza26ejkcmyaxq.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [],
reevaluate_automatically: false
}
],
parent_id: nil
}
],
hub_id: "personal-hub",
deployment_group_id: nil,
app_settings: %{
__struct__: Livebook.Notebook.AppSettings,
password: "tofl6hniv7k2segi",
slug: nil,
multi_session: false,
auto_shutdown_ms: nil,
output_type: :all,
show_existing_sessions: false,
show_source: false,
zero_downtime: false,
access_type: :protected
},
file_entries: [],
autosave_interval_s: 5,
quarantine_file_entry_names: MapSet.new([]),
teams_enabled: false,
output_counter: 2313,
setup_section: %{
id: "setup-section",
name: "Setup",
__struct__: Livebook.Notebook.Section,
cells: [
%{
id: "setup",
source: "Mix.install([\n {:kino, \"~> 0.17.0\"},\n {:kino_progress_bar, github: \"acalejos/kino_progress_bar\"},\n {:req_llm, \"~> 1.0\"}\n])",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2230, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}}],
continue_on_error: false,
reevaluate_automatically: false
}
],
parent_id: nil
},
default_language: :elixir,
hub_secret_names: ["OPENROUTER_API_KEY"],
leading_comments: [],
persist_outputs: true
}
Now we have what we need to get the context:
all = Enum.flat_map(notebook.sections, & &1.cells)
idx = Enum.find_index(all, &(&1.id == Kino.Cebola.get_current_cell_id()))
cells = Enum.take(all, idx+1)[
%{
id: "ff5ouf2itzeuzaoi",
source: "We want to allow a user *pair program* with an LLM, keeping a conversation about the notebook they are actively creating. This idea took inspiration from [Jeremy Howard's](https://en.wikipedia.org/wiki/Jeremy_Howard_(entrepreneur)) [Solve.it](https://solve.it.com/?via_id=u0nb5yov), an app and methodology designed to augment human capabilities with AI (and a refreshing alternative to the mind numbing nature of of vibe coding). The SolveIt app is Python-based, and I have been really enjoying the method, but wanted to bring it to Elixir where I also code with notebooks. \n\n(*The [link](https://solve.it.com/?via_id=u0nb5yov) above includes a 15% discount if you’d like to enroll in the SolveIt course.*)\n\n`Prompt Buddy` will be a Livebook Smart Cell that allow the user to give a prompt in the context of all the cells that precede it in the notebook. \n\nTo achieve this, we need to:\n\n1. Connect to an LLM, send a prompt, receive a response.\n - ideally stream the response so the user don't have to wait until it;s complete to see something\n1. Create a Smart Cell UI that will allow the user to input the prompt\n1. Add the context (the source and outputs of the cells prior to the Smart Cell being inserted)\n\nI already have some experience with [ReqLLM](https://github.com/agentjido/req_llm) so I believe that the first step will be quite easy. I have never created a Smart Cell before, but I saw [one](https://www.youtube.com/watch?v=Yw_EqX_KAw4&t=59s) and [other](https://www.youtube.com/watch?v=2YVfHNFLROw&t=72s) video that made me confident. \n\nThe problem is the 3rd step, passing the context, as it will require some introspection: accessing the current notebook state in real time to collect information about the cells that precede the current one. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "26vceah6tgvqbukw",
source: "To connect to an LLM with ReqLLM we need an API KEY. I decided to use [OpenRouter](openrouter.ai) and added the token in the Secrets menu in Livebook's navbar. It adds `LB_OPENROUTER_API_KEY` to the environment, which I then access:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "7sqdcveiajogqpzi",
source: "ReqLLM.put_key(:openrouter_api_key, System.get_env(\"LB_OPENROUTER_API_KEY\"))",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2231, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}}],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "eddrynskxv7aa24r",
source: "Using ReqLLM is a breeze.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "7x767mxlhz4nomz4",
source: "\nmodel = \"openrouter:anthropic/claude-3-haiku\"\nmessages = [\n ReqLLM.Context.system(\"You are a sassy storyteller.\"),\n ReqLLM.Context.user(\"Protagonist: Miss Plum\"),\n ReqLLM.Context.user(\"Location: In Paris\"),\n ReqLLM.Context.user(\"Tell me a 100 words story with the input I gave you\")\n ]\n{:ok, response} = ReqLLM.stream_text(model, messages)\nReqLLM.StreamResponse.tokens(response)\n|> Stream.each(&IO.write/1)\n|> Stream.run()\n",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2249, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}},
{2232,
%{
type: :terminal_text,
text: "Here is a 100-word story with the given input:\n\nMiss Plum sauntered down the Champs-Élysées, her parasol twirling in the Parisian breeze. The city of lights shimmered around her, but the feisty American heiress had no time for mere sightseeing. \n\n\"Honestly, this place is far too stuffy for my liking,\" she huffed, her crimson lips pursed in a pout. \"I simply must find a way to liven things up around here.\"\n\nWith a mischievous glint in her eye, Miss Plum headed for the Louvre, already formulating a plan to inject a little scandal into the staid Parisian social scene. After all, a girl's gotta do what a girl's gotta do.",
chunk: true
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "zvfznjzxttjeymmi",
source: "ReqLLM has some nice functionalities like enforcing schemas and bringing usage and cost information. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "7pflqp6plsrknmrs",
source: "usage = ReqLLM.StreamResponse.usage(response)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2250,
%{
type: :terminal_text,
text: "%{\n \e[34minput:\e[0m \e[34m42\e[0m,\n \e[34moutput:\e[0m \e[34m187\e[0m,\n \e[34moutput_tokens:\e[0m \e[34m187\e[0m,\n \e[34minput_tokens:\e[0m \e[34m42\e[0m,\n \e[34mreasoning:\e[0m \e[34m0\e[0m,\n \e[34mtotal_tokens:\e[0m \e[34m229\e[0m,\n \e[34mcached_input:\e[0m \e[34m0\e[0m\n}",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "eob6jheusp3sn4g5",
source: "I don't know why it did not show the cost this time, never mind!",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "6ecraejfebtevvk4",
source: "The next step is to create a `Smart Cell`. I know the Smart Cell will need an UI, so I will start with that. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "5bnqp37534aeize5",
source: "### A ~Dumb~ Simple Smart Cell First",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "xtpy3blyv24cvvv3",
source: "The idea here is just to understand the annatomy of a Smart Cell",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "wkahqpmfqajjbpsj",
source: "\ndefmodule Kino.Dumb do\n use Kino.JS\n use Kino.JS.Live # bi-directional communication with the frontend\n use Kino.SmartCell, name: \"Dumb\"\n\n # ------------------------------------------------------------\n # 1. INITIALIZATION\n # ------------------------------------------------------------\n @impl true\n # Called when the Smart Cell is first created.\n # `attrs` contains saved attributes (if any) and `ctx` is the runtime context.\n # This is helpful when the notebook is loaded from a file.\n # Here we do nothing special—just return {:ok, ctx} to keep the context.\n def init(attrs, ctx), do: {:ok, ctx}\n\n # ------------------------------------------------------------\n # 2. FRONTEND CONNECTION\n # ------------------------------------------------------------\n @impl true\n # Called when the frontend (the JavaScript side) connects to the backend.\n # We can send initial data to the frontend here.\n # Returning {:ok, payload, ctx} sends `payload` to JS; we send an empty map.\n def handle_connect(ctx),\n do: {:ok, %{}, ctx}\n\n # ------------------------------------------------------------\n # 3. SERIALIZATION\n # ------------------------------------------------------------\n @impl true\n # Defines which data from the context is stored as \"attributes\" of the cell\n # when saving or exporting the notebook.\n # Here we have nothing to persist, so we return an empty map.\n def to_attrs(ctx), do: %{}\n\n # ------------------------------------------------------------\n # 4. CODE GENERATION\n # ------------------------------------------------------------\n @impl true\n # Defines how the cell’s content is turned into Elixir source code.\n # This is what will appear in the notebook when the Smart Cell \"expands.\"\n # Clicking the `< >` icon in the top of the cell.\n def to_source(attrs) do\n quote do\n \"Nothing to show\"\n end\n # Kino helper that converts a quoted expression into a nicely formatted string.\n |> Kino.SmartCell.quoted_to_string()\n end\n\n # ------------------------------------------------------------\n # 5. FRONTEND ASSET (JavaScript)\n # ------------------------------------------------------------\n # Every Smart Cell can define a JS asset that runs in the browser.\n # The string returned here is bundled and served to the frontend.\n # It can use Livebook’s JS context API (ctx) to receive events or send messages.\n asset \"main.js\" do\n \"\"\"\n // Entry point called when the JS component is initialized.\n // `ctx` is the communication channel; `payload` is what we sent in handle_connect.\n export function init(ctx, payload) {\n // No frontend UI yet—this is intentionally \"dumb\".\n }\n \"\"\"\n end\nend\n\n# ------------------------------------------------------------\n# 6. REGISTRATION\n# ------------------------------------------------------------\n# Registers this Smart Cell so it appears in Livebook’s Smart Cell picker.\nKino.SmartCell.register(Kino.Dumb)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2252, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}},
{2251,
%{
type: :terminal_text,
text: "\e[33mwarning:\e[0m variable \"attrs\" is unused (if the variable is not meant to be used, prefix it with an underscore)\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:15: Kino.Dumb.init/2\n\n\e[33mwarning:\e[0m variable \"ctx\" is unused (if the variable is not meant to be used, prefix it with an underscore)\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:34: Kino.Dumb.to_attrs/1\n\n\e[33mwarning:\e[0m variable \"attrs\" is unused (if the variable is not meant to be used, prefix it with an underscore)\n└─ Code/Learn/elixir/prompt_smartcell/prompt_buddy.livemd#cell:wkahqpmfqajjbpsj:43: Kino.Dumb.to_source/1\n\n",
chunk: true
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "bmk2vapfsiobcipp",
source: "Click the `toggle source` icon:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "j4dldp26mnhfxlju",
chunks: nil,
editor: nil,
source: "\"Nothing to show\"",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.Dumb",
attrs: %{},
js_view: %{
pid: #PID<0.972.0>,
ref: "j4dldp26mnhfxlju",
assets: %{
hash: "hlcqf2ecowtm3jikg6o5afrqty",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_Dumb/hlcqf2ecowtm3jikg6o5afrqty.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [{2253, %{type: :terminal_text, text: "\e[32m\"Nothing to show\"\e[0m", chunk: false}}],
reevaluate_automatically: false
},
%{
id: "dczxm3zi5gy26kxk",
source: "### Book Summarizer UI",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "ub334sp3nykfx3nq",
source: "The Kino library in Livebook allow to build simple UIs.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "hkv65ekxlwt4pcyx",
source: "import Kino.Shorts\n\nif key = System.get_env(\"LB_OPENROUTER_API_KEY\") do\n ReqLLM.put_key(:openrouter_api_key, key)\nend\n\nmodel = \"openrouter:anthropic/claude-3-haiku\"\n\n# UI\nform =\n Kino.Control.form(\n [prompt: Kino.Input.textarea(\"Book Title:\", default: \"\")],\n submit: \"Submit\"\n )\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn event ->\n # Hide the form after submit\n Kino.Frame.render(form_frame, Kino.nothing())\n\n book = String.trim(event.data.prompt || \"Hamlet\")\n Kino.Frame.render(user_output, Kino.Markdown.new(\"**User**:\\n\\n\" <> book))\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\"Summarize the given book in under 300 words, focusing on plot, themes, characters, and context.\"),\n ReqLLM.Context.user(\"Book title: \" <> book)\n ]\n # Stream answer\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n # Render every N tokens to keep UI snappy (adjust N if you like)\n n_every = 24\n\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new = acc <> token\n n2 = n + 1\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> new))\n end\n {new, n2}\n end)\n\n # Final flush + checkmark\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\")\n )\n end\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])\n",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2258,
%{
type: :grid,
columns: 1,
outputs: [
{2255,
%{
type: :frame,
ref: "-576460752303415838",
outputs: [
{2283,
%{
type: :terminal_text,
text: "\e[34m:\"do not show this result in output\"\e[0m",
chunk: false
}}
],
placeholder: true
}},
{2256,
%{
type: :frame,
ref: "-576460752303415806",
outputs: [
{2284, %{type: :markdown, text: "**User**:\n\nSkin in the game", chunk: false}}
],
placeholder: true
}},
{2257,
%{
type: :frame,
ref: "-576460752303415774",
outputs: [
{2292,
%{
type: :markdown,
text: "**Assistant**:\n\nHere is a 300-word summary of the book \"Skin in the Game\" by Nassim Nicholas Taleb:\n\n\"Skin in the Game\" is a thought-provoking exploration of the importance of having \"skin in the game\" - a real stake or personal risk in the outcomes of one's decisions and actions. Taleb argues that this principle is crucial for promoting fairness, accountability, and sound decision-making in various domains, from finance and business to politics and academia.\n\nThe central thesis is that people who don't have a real stake in the consequences of their choices should not be trusted to make important decisions. Taleb examines how a lack of skin in the game has led to significant problems, such as the 2008 financial crisis, where many Wall Street executives were insulated from the fallout of their risky bets.\n\nTaleb delves into the historical origins of the skin in the game concept, tracing it back to ancient traditions and ethical frameworks. He also explores how the principle applies to modern life, from the importance of craftspeople and artisans having a personal investment in their work, to the dangers of experts and policymakers who are removed from the real-world impact of their recommendations.\n\nThroughout the book, Taleb's writing is characterized by his trademark blend of erudition, wit, and iconoclastic style. He challenges readers to question conventional wisdom and to demand more accountability from those in positions of power and influence.\n\n\"Skin in the Game\" ultimately argues that embracing the skin in the game principle is essential for creating a more just, robust, and antifragile society. It's a thought-provoking and engaging read that will likely inspire readers to reevaluate their own roles and responsibilities in the world. ✅",
chunk: false
}}
],
placeholder: true
}}
],
boxed: false,
gap: 8,
max_height: nil
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "pb3ocn3nne7uylem",
source: "### Book Worm Smart Cell",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "kq7tghzjzhitkkl6",
source: "Now we have everything to create a smart cell that summarizes books.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "2zw7mp536rf5iudk",
source: "defmodule Kino.BookWorm do\n use Kino.JS\n use Kino.JS.Live\n use Kino.SmartCell, name: \"Book Worm\"\n\n @impl true\n def init(attrs, ctx) do\n {:ok,\n assign(ctx,\n model: attrs[\"model\"] || \"openrouter:anthropic/claude-3-haiku\",\n n_every: attrs[\"n_every\"] || 24 # render every n_every streamed tokens\n )}\n end\n\n @impl true\n def handle_connect(ctx) do\n {:ok,\n %{\n model: ctx.assigns[:model],\n n_every: ctx.assigns[:n_every]\n }, ctx}\n end\n\n @impl true\n def to_attrs(ctx) do\n %{\n \"model\" => ctx.assigns[:model],\n \"n_every\" => ctx.assigns[:n_every]\n }\n end\n\n @impl true\n def to_source(attrs) do\n quote do\n # ---------- Book Worm UI (auto-generated by Smart Cell) ----------\n model = unquote(attrs[\"model\"])\n n_every = unquote(attrs[\"n_every\"])\n\n import Kino.Shorts\n\n # --- UI skeleton\n form =\n Kino.Control.form(\n [\n book: Kino.Input.text(\"Book Title\", default: \"\")\n ],\n submit: \"Summarize\"\n )\n\n form_frame = frame()\n user_output = frame()\n assistant_output = frame()\n\n Kino.Frame.render(form_frame, form)\n\n Kino.listen(form, fn ev ->\n # Optional: temporarily hide the form to avoid double submits\n Kino.Frame.render(form_frame, Kino.nothing())\n\n book_title = String.trim(ev.data.book || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\"**Book**:\\n\\n\" <> if(book_title == \"\", do: \"_(empty)_\", else: book_title))\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Summary**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\n \"Summarize the given book in under 500 words, focusing on plot, themes, characters, and context.\"\n ),\n ReqLLM.Context.user(\"Book title: \" <> (book_title == \"\" && \"(empty)\" || book_title))\n ]\n\n # Stream answer\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new_acc = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> new_acc)\n )\n end\n\n {new_acc, n2}\n end)\n\n # Final flush + checkmark\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\")\n )\n end\n\n # Re-show the form after processing\n Kino.Frame.render(form_frame, form)\n end)\n end)\n\n grid([\n form_frame,\n user_output,\n assistant_output\n ])\n # ---------- /Book Worm UI ----------\n end\n |> Kino.SmartCell.quoted_to_string()\n end\n\n asset \"main.js\" do\n \"\"\"\n export function init(_ctx, _payload) {\n // No client-side wiring needed yet\n }\n \"\"\"\n end\nend\n\nKino.SmartCell.register(Kino.BookWorm)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2259, %{type: :terminal_text, text: "\e[34m:ok\e[0m", chunk: false}}],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "kghc2s4ywzhltphe",
chunks: nil,
editor: nil,
source: "model = \"openrouter:anthropic/claude-3-haiku\"\nn_every = 24\nimport Kino.Shorts\n\nform =\n Kino.Control.form([book: Kino.Input.text(\"Book Title\", default: \"Hamlet\")],\n submit: \"Summarize\"\n )\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn ev ->\n Kino.Frame.render(form_frame, Kino.nothing())\n book_title = String.trim(ev.data.book || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\n \"**Book**:\\n\\n\" <>\n if book_title == \"\" do\n \"_(empty)_\"\n else\n book_title\n end\n )\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Summary**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\n \"Summarize the given book in under 500 words, focusing on plot, themes, characters, and context.\"\n ),\n ReqLLM.Context.user(\"Book title: \" <> ((book_title == \"\" && \"(empty)\") || book_title))\n ]\n\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new_acc = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> new_acc)\n )\n end\n\n {new_acc, n2}\n end)\n\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Summary**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\n \"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\"\n )\n )\n end\n\n Kino.Frame.render(form_frame, form)\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.BookWorm",
attrs: %{},
js_view: %{
pid: #PID<0.1402.0>,
ref: "kghc2s4ywzhltphe",
assets: %{
hash: "c5fsrrj7l3ud5kfwhpwvxt2t64",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_BookWorm/c5fsrrj7l3ud5kfwhpwvxt2t64.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [
{2264,
%{
type: :grid,
columns: 1,
outputs: [
{2261,
%{
type: :frame,
ref: "-576460752303419324",
outputs: [
{2260,
%{
type: :control,
ref: "vcmvav2i5msp2ot5adp6vp4vbipaopfe",
attrs: %{type: :form, fields: [...], ...},
destination: {Kino.SubscriptionManager, ...}
}}
],
placeholder: true
}},
{2262, %{type: :frame, ref: "-576460752303419292", outputs: [], placeholder: true}},
{2263, %{type: :frame, ref: "-576460752303419260", outputs: [], placeholder: true}}
],
boxed: false,
gap: 8,
max_height: nil
}}
],
reevaluate_automatically: false
},
%{
id: "pkwbcdh2zwixvnet",
chunks: nil,
editor: nil,
source: "model = \"openrouter:anthropic/claude-3-haiku\"\nn_every = 24\n\nif key = System.get_env(\"LB_OPENROUTER_API_KEY\") do\n ReqLLM.put_key(:openrouter_api_key, key)\nend\n\nimport Kino.Shorts\n\nform =\n Kino.Control.form([book: Kino.Input.text(\"Book title\", default: \"Romeo and Juliet\")],\n submit: \"Summarize\"\n )\n\nform_frame = frame()\nuser_output = frame()\nassistant_output = frame()\nKino.Frame.render(form_frame, form)\n\nKino.listen(form, fn ev ->\n Kino.Frame.render(form_frame, Kino.nothing())\n book_title = String.trim(ev.data.book || \"\")\n\n Kino.Frame.render(\n user_output,\n Kino.Markdown.new(\n \"**User**:\\n\\n\" <>\n if book_title == \"\" do\n \"_(empty)_\"\n else\n book_title\n end\n )\n )\n\n Kino.Frame.render(assistant_output, Kino.Markdown.new(\"**Assistant**:\\n\\n\"))\n\n messages = [\n ReqLLM.Context.system(\n \"Summarize the given book in under 1000 words, focusing on plot, themes, characters, and context.\"\n ),\n ReqLLM.Context.user(\"Book title: \" <> ((book_title == \"\" && \"(empty)\") || book_title))\n ]\n\n Task.start(fn ->\n case ReqLLM.stream_text(model, messages) do\n {:ok, response} ->\n {final, _count} =\n ReqLLM.StreamResponse.tokens(response)\n |> Enum.reduce({\"\", 0}, fn token, {acc, n} ->\n new_acc = acc <> token\n n2 = n + 1\n\n if rem(n2, n_every) == 0 do\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> new_acc)\n )\n end\n\n {new_acc, n2}\n end)\n\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\"**Assistant**:\\n\\n\" <> final <> \" ✅\")\n )\n\n {:error, err} ->\n Kino.Frame.render(\n assistant_output,\n Kino.Markdown.new(\n \"**Error**:\\n\\n```\\n\" <> inspect(err, pretty: true) <> \"\\n```\"\n )\n )\n end\n\n Kino.Frame.render(form_frame, form)\n end)\nend)\n\ngrid([form_frame, user_output, assistant_output])",
__struct__: Livebook.Notebook.Cell.Smart,
kind: "Elixir.Kino.BookWorm",
attrs: %{},
js_view: %{
pid: #PID<0.1072.0>,
ref: "pkwbcdh2zwixvnet",
assets: %{
hash: "c5fsrrj7l3ud5kfwhpwvxt2t64",
archive_path: "/Users/fredguth/Library/Caches/mix/installs/elixir-1.18.3-erts-15.2.6/2fab7b2b6e02e9be275d3061eb6bcfdf/_build/dev/lib/kino/priv/assets/Kino_BookWorm/c5fsrrj7l3ud5kfwhpwvxt2t64.tar.gz",
cdn_url: nil,
js_path: "main.js"
}
},
outputs: [
{2269,
%{
type: :grid,
columns: 1,
outputs: [
{2266,
%{
type: :frame,
ref: "-576460752303412255",
outputs: [
{2308,
%{
type: :control,
ref: "lpz7dz757xbfwbcxdj3n2gqfvybj5eqm",
attrs: %{type: :form, ...},
destination: {...}
}}
],
placeholder: true
}},
{2267,
%{
type: :frame,
ref: "-576460752303412223",
outputs: [
{2294, %{type: :markdown, text: "**User**:\n\nRomeo and Juliet", chunk: false}}
],
placeholder: true
}},
{2268,
%{
type: :frame,
ref: "-576460752303412191",
outputs: [
{2307,
%{
type: :markdown,
text: "**Assistant**:\n\nHere is a summary of the play Romeo and Juliet by William Shakespeare in under 1000 words:\n\nRomeo and Juliet is a famous tragic love story set in the Italian city of Verona. The play centers around two young star-crossed lovers, Romeo and Juliet, whose families, the Montagues and Capulets, are engaged in a long-standing feud.\n\nThe story begins with a brawl breaking out between the servants of the two feuding families. The Prince of Verona intervenes and declares that any further violence between the Montagues and Capulets will be punished by death. \n\nRomeo, a Montague, is infatuated with a woman named Rosaline, but at a Capulet party, he instead falls in love at first sight with Juliet, the daughter of Lord Capulet. Juliet reciprocates his feelings, and they decide to get married in secret, with the help of Friar Laurence.\n\nThe next day, Romeo gets into a fight with Juliet's cousin Tybalt and kills him. As a result, Romeo is banished from Verona. On their wedding night, Romeo and Juliet consummate their marriage, but are forced to part ways when morning comes. \n\nUnaware of the secret marriage, Juliet's parents arrange for her to marry a man named Paris. Desperate to avoid this wedding, Juliet seeks Friar Laurence's help. He devises a plan where Juliet will fake her own death by taking a potion that will make her appear dead for 42 hours. During this time, Friar Laurence will send a message to Romeo informing him of the plan so he can come rescue Juliet from the tomb.\n\nHowever, the message does not reach Romeo in time. He hears only of Juliet's \"death\" and, grief-stricken, kills himself by drinking poison at her tomb. When Juliet wakes up and finds Romeo dead, she kills herself as well by stabbing herself with Romeo's dagger.\n\nThe tragic deaths of the two young lovers eventually put an end to the long-standing feud between the Montague and Capulet families. The play explores themes of fate, young love, and the destructive nature of family rivalries.\n\nThe central characters are Romeo, a young Montague who is impulsive and passionate; Juliet, a Capulet who is intelligent and loyal to her family; Friar Laurence, who tries to help the young lovers but his plan goes awry; and the feuding patriarchs, Lord Montague and Lord Capulet, whose rivalry drives much of the plot. \n\nThe play is set against the backdrop of 16th century Verona, a society sharply divided along family lines. The young lovers' struggle to be together in the face of this deep-rooted conflict forms the core of the tragedy.\n\nOverall, Romeo and Juliet is regarded as one of Shakespeare's most famous and enduring works, a timeless tale of young love and the high cost of family feuds. ✅",
...
}}
],
placeholder: true
}}
],
boxed: false,
gap: 8,
max_height: nil
}}
],
reevaluate_automatically: false
},
%{
id: "6xizpfzuixvjtb2z",
source: "We already know how to connect to LLM and how to create a Smart Cell. The last step is the most difficult, do some `reflection` and find out the cells that precede the current one. \n\nWith some help from [Hugo](https://gist.github.com/hugobarauna), I found out that I need to:\n- Know the `current_cell_id`, the id of the smart cell;",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "ehjb55g2cy5h3seg",
source: "#### current_cell_id\nThis is the easiest information to gather, Kino helps us:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "yd76wwlitpax7qfd",
source: "Kino.Bridge.get_evaluation_file()\n |> String.split(\"#cell:\")\n |> List.last()",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2270, %{type: :terminal_text, text: "\e[32m\"yd76wwlitpax7qfd\"\e[0m", chunk: false}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "rix2wxxkw27nuzrr",
source: "You can check that it is right by inspect the notebook HTML. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "re3p5baih7khygv5",
source: "#### livebook node",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "sxkqwayjshraye2o",
source: "The session of me using this notebook is a node in the Elixir BEAM. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "jyhsqdrszxxkmuvt",
source: "notebook_node = node()",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2271,
%{
type: :terminal_text,
text: "\e[34m:\"[email protected]\"\e[0m",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "qiauc5dflu75c47m",
source: "But we are interested in another node, the node of the Livebook app itself. ",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "tmkhcjdgqhaga5xb",
source: "livebook_node = \n node()\n |> Atom.to_string()\n |> String.replace(~r/--[^@]+@/, \"@\")\n |> String.to_atom()",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2272,
%{type: :terminal_text, text: "\e[34m:\"[email protected]\"\e[0m", chunk: false}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "z34qlc6dql425fcz",
source: "As you have seen, every notebook node has the node address of its livebook.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{id: "akxct6dnc2s7dxgy", source: "#### sessions", __struct__: Livebook.Notebook.Cell.Markdown},
%{
id: "cwtt66yb5w5jndh4",
source: "sessions = :erpc.call(livebook_node, Livebook.Tracker, :list_sessions, [])",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2273,
%{
type: :terminal_text,
text: "[\n %{\n \e[34mid:\e[0m \e[32m\"356mdgtglekdibxpicxgpgd45ila3nwledjcxdlqcdwdyvmb\"\e[0m,\n \e[34mpid:\e[0m #PID<13576.992.0>,\n \e[34mfile:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Downloads/internals.livemd\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.990.0>\n },\n \e[34mmode:\e[0m \e[34m:default\e[0m,\n \e[34morigin:\e[0m {\e[34m:file\e[0m,\n %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Downloads/internals.livemd\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.990.0>\n }},\n \e[34m__struct__:\e[0m \e[34mLivebook.Session\e[0m,\n \e[34mmemory_usage:\e[0m %{\n \e[34mruntime:\e[0m %{\n \e[34matom:\e[0m \e[34m909553\e[0m,\n \e[34mbinary:\e[0m \e[34m893608\e[0m,\n \e[34mcode:\e[0m \e[34m15395623\e[0m,\n \e[34mets:\e[0m \e[34m1380880\e[0m,\n \e[34mprocesses:\e[0m \e[34m17645336\e[0m,\n \e[34mtotal:\e[0m \e[34m52826313\e[0m,\n \e[34mother:\e[0m \e[34m16601313\e[0m\n },\n \e[34msystem:\e[0m %{\e[34mfree:\e[0m \e[34m4703502336\e[0m, \e[34mtotal:\e[0m \e[34m17179869184\e[0m}\n },\n \e[34mfiles_dir:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Downloads/files/\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.990.0>\n },\n \e[34mcreated_at:\e[0m ~U[2025-11-04 20:58:46.352950Z],\n \e[34mnotebook_name:\e[0m \e[32m\"Livebook internals - fork\"\e[0m\n },\n %{\n \e[34mid:\e[0m \e[32m\"356mdgtglekdibxpicxgpgd45ila3nxi5bzp4k6qs5jsnkyz\"\e[0m,\n \e[34mpid:\e[0m #PID<13576.1462.0>,\n \e[34mfile:\e[0m \e[35mnil\e[0m,\n \e[34mmode:\e[0m \e[34m:default\e[0m,\n \e[34morigin:\e[0m \e[35mnil\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.Session\e[0m,\n \e[34mmemory_usage:\e[0m %{\n \e[34mruntime:\e[0m %{\n \e[34matom:\e[0m \e[34m737513\e[0m,\n \e[34mbinary:\e[0m \e[34m970624\e[0m,\n \e[34mcode:\e[0m \e[34m15489858\e[0m,\n \e[34mets:\e[0m \e[34m1407616\e[0m,\n \e[34mprocesses:\e[0m \e[34m19909248\e[0m,\n \e[34mtotal:\e[0m \e[34m55176348\e[0m,\n \e[34mother:\e[0m \e[34m16661489\e[0m\n },\n \e[34msystem:\e[0m %{\e[34mfree:\e[0m \e[34m4765597696\e[0m, \e[34mtotal:\e[0m \e[34m17179869184\e[0m}\n },\n \e[34mfiles_dir:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_04/21_44_nkyz/files/\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n \e[34mfile_system_module:\e[0m \e[34mLivebook.FileSystem.Local\e[0m,\n \e[34morigin_pid:\e[0m #PID<13576.1462.0>\n },\n \e[34mcreated_at:\e[0m ~U[2025-11-04 21:44:36.343424Z],\n \e[34mnotebook_name:\e[0m \e[32m\"Prompt SmartCell\"\e[0m\n },\n %{\n \e[34mid:\e[0m \e[32m\"356mdgtglekdibxpicxgpgd45ila3nx4kiuywub2hewtnxjm\"\e[0m,\n \e[34mpid:\e[0m #PID<13576.2286.0>,\n \e[34mfile:\e[0m \e[35mnil\e[0m,\n \e[34mmode:\e[0m \e[34m:default\e[0m,\n \e[34morigin:\e[0m \e[35mnil\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.Session\e[0m,\n \e[34mmemory_usage:\e[0m %{\n \e[34mruntime:\e[0m %{\n \e[34matom:\e[0m \e[34m581841\e[0m,\n \e[34mbinary:\e[0m \e[34m877440\e[0m,\n \e[34mcode:\e[0m \e[34m11077161\e[0m,\n \e[34mets:\e[0m \e[34m1125512\e[0m,\n \e[34mprocesses:\e[0m \e[34m17764600\e[0m,\n \e[34mtotal:\e[0m \e[34m46770443\e[0m,\n \e[34mother:\e[0m \e[34m15343889\e[0m\n },\n \e[34msystem:\e[0m %{\e[34mfree:\e[0m \e[34m4765597696\e[0m, \e[34mtotal:\e[0m \e[34m17179869184\e[0m}\n },\n \e[34mfiles_dir:\e[0m %{\n \e[34mpath:\e[0m \e[32m\"/Users/fredguth/Library/Application Support/livebook/autosaved/2025_11_05/00_33_nxjm/files/\"\e[0m,\n \e[34m__struct__:\e[0m \e[34mLivebook.FileSystem.File\e[0m,\n \e[34mfile_system_id:\e[0m \e[32m\"local\"\e[0m,\n " <> ...,
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "crl6gho4gt2xoer5",
source: "In a few moments you will see that we need to know our current session id to filter this map.\nThis information is in the URL, but there isn't a direct Elixir-side API that exposes the browser's `document.baseURI`. \n\nBut Let's remember we will create a SmartCell and smart cells have access to JS. Let's use it to get the session_id.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{id: "lhamwfpjmlcynoiv", source: "#### session_id", __struct__: Livebook.Notebook.Cell.Markdown},
%{
id: "mqd4xwmbzbcmqzkf",
source: "Let's modify the Dumb Smart Cell to get the `session_id`.",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "rgefdrrqrwyidu4w",
source: "\ndefmodule Kino.Cebola do\n use Kino.JS\n use Kino.JS.Live \n use Kino.SmartCell, name: \"Cebola\"\n\n def new(), do: Kino.JS.Live.new(__MODULE__, %{})\n def get_session_id(kino), do: Kino.JS.Live.call(kino, :get_session_id)\n def get_current_cell_id() do\n Kino.Bridge.get_evaluation_file()\n |> String.split(\"#cell:\")\n |> List.last()\n end\n @impl true\n def init(_payload, ctx) do\n {:ok, assign(ctx, session_id: nil)}\n end\n \n @impl true\n def handle_connect(ctx),\n do: {:ok, %{}, ctx}\n\n @impl true\n def to_attrs(_), do: %{}\n \n @impl true\n def to_source(_) do\n end\n\n @impl true\n def handle_event(\"set_session_id\", session_url, ctx) do\n session_id =\n case Regex.run(~r{/sessions/([^/]+)/}, session_url) do\n [_, id] -> id\n _ -> nil\n end\n\n {:noreply, assign(ctx, session_id: session_id)}\n end\n\n @impl true\n def handle_call(:get_session_id, _from, ctx) do\n {:reply, ctx.assigns.session_id, ctx}\n end\n \n asset \"main.js\" do\n \"\"\"\n export function init(ctx) {\n // When the client connects, send the page baseURI so the backend can parse the session id.\n ctx.pushEvent(\"set_session_id\", document.baseURI);\n }\n \"\"\"\n end\nend\n\n",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2274,
%{
type: :terminal_text,
text: "{\e[34m:module\e[0m, \e[34mKino.Cebola\e[0m, <<\e[34m70\e[0m, \e[34m79\e[0m, \e[34m82\e[0m, \e[34m49\e[0m, \e[34m0\e[0m, \e[34m0\e[0m, \e[34m30\e[0m, ...>>, \e[34m:ok\e[0m}",
chunk: false
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "vwh6rdjncqorqr3n",
source: "cebola = Kino.Cebola.new()\n\n# You must render it so the JS `init` runs and pushes the baseURI.\nKino.render(cebola)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2310, %{type: :js, export: false, js_view: %{...}}},
{2309, %{type: :js, export: false, ...}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{
id: "t2mo5sjxgnzsjn2u",
source: "session_id = Kino.Cebola.get_session_id(cebola)\nsession_id",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [
{2311,
%{
type: :terminal_text,
text: "\e[32m\"356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt\"\e[0m",
...
}}
],
continue_on_error: false,
reevaluate_automatically: false
},
%{id: "22u3ihjzowyoej7t", source: "#### session.pid", __struct__: Livebook.Notebook.Cell.Markdown},
%{
id: "2zrkhmqhp7nchf5j",
source: "session = Enum.find(sessions, &(&1.id == session_id))\nsession.pid",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [{2312, %{...}}],
continue_on_error: false,
reevaluate_automatically: false
},
%{id: "nlnsdq3cglgi3kxp", source: "#### notebook", __struct__: Livebook.Notebook.Cell.Markdown},
%{
id: "gxmxrkq3fstyk2lw",
source: "notebook = :erpc.call(livebook_node, Livebook.Session, :get_notebook, [session.pid])",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
outputs: [],
continue_on_error: false,
...
},
%{
id: "zs2d6acskfyt3smk",
source: "#### precedent_cells\nNow we have what we need to get the context:",
__struct__: Livebook.Notebook.Cell.Markdown
},
%{
id: "trlnloy2dms2zxzf",
source: "all = Enum.flat_map(notebook.sections, & &1.cells)\nidx = Enum.find_index(all, &(&1.id == Kino.Cebola.get_current_cell_id()))\ncells = Enum.take(all, idx+1)",
__struct__: Livebook.Notebook.Cell.Code,
language: :elixir,
...
}
]
Phew!
defmodule Kino.PromptBuddy do
use Kino.JS
use Kino.JS.Live
use Kino.SmartCell, name: "Prompt Buddy"
# -- Public API --------------------------------------------------------------
def new(), do: Kino.JS.Live.new(__MODULE__, %{})
def get_session_id(kino), do: Kino.JS.Live.call(kino, :get_session_id)
def get_current_cell_id() do
Kino.Bridge.get_evaluation_file()
|> String.split("#cell:")
|> List.last()
end
def get_notebook(session_id) do
node_norm =
node()
|> Atom.to_string()
|> String.replace(~r/--[^@]+@/, "@")
|> String.to_atom()
Node.set_cookie(node_norm, Node.get_cookie())
sessions = :erpc.call(node_norm, Livebook.Tracker, :list_sessions, [])
case Enum.find(sessions, &(&1.id == session_id)) do
nil -> {:error, :session_not_found}
s -> {:ok, :erpc.call(node_norm, Livebook.Session, :get_notebook, [s.pid])}
end
end
defp cells_until(notebook, current_cell_id) do
all = Enum.flat_map(notebook.sections, & &1.cells)
idx = Enum.find_index(all, &(&1.id == current_cell_id)) || length(all) - 1
{:ok, Enum.take(all, idx + 1)}
end
def build_messages(session_id, current_cell_id) do
with true <- is_binary(session_id),
true <- is_binary(current_cell_id),
{:ok, notebook} <- get_notebook(session_id),
{:ok, cells} <- cells_until(notebook, current_cell_id) do
cells
|> Enum.map(&String.trim(&1.source || ""))
|> Enum.reject(&(&1 == ""))
|> Enum.with_index()
|> Enum.map(fn
{text, 0} -> ReqLLM.Context.system(text)
{text, _} -> ReqLLM.Context.user(text)
end)
else
_ -> []
end
end
@impl true
def init(attrs, ctx) do
{:ok,
assign(ctx,
session_id: attrs["session_id"],
model: attrs["model"] || "openrouter:anthropic/claude-sonnet-4.5",
n_every: attrs["n_every"] || 24
)}
end
@impl true
def handle_connect(ctx) do
{:ok,
%{
session_id: ctx.assigns[:session_id],
model: ctx.assigns[:model],
n_every: ctx.assigns[:n_every]
}, ctx}
end
@impl true
def handle_event("set_session_id", session_url, ctx) do
session_id =
case Regex.run(~r{/sessions/([^/]+)/}, session_url) do
[_, id] -> id
_ -> nil
end
{:noreply, assign(ctx, session_id: session_id)}
end
@impl true
def to_attrs(ctx) do
%{
"session_id" => ctx.assigns[:session_id],
"model" => ctx.assigns[:model],
"n_every" => ctx.assigns[:n_every]
}
end
@impl true
def to_source(attrs) do
quote do
# ---------- PromptBuddy UI (auto-generated by SmartCell) ----------
model = unquote(attrs["model"])
n_every = unquote(attrs["n_every"])
session_id = unquote(attrs["session_id"])
current_cell_id = Kino.PromptBuddy.get_current_cell_id()
import Kino.Shorts
# --- UI skeleton
form =
Kino.Control.form(
[
prompt: Kino.Input.textarea("Prompt", default: "")
],
submit: "Send"
)
form_frame = frame()
user_output = frame()
assistant_output = frame()
Kino.Frame.render(form_frame, form)
Kino.listen(form, fn ev ->
# Hide form to discourage double-submit; re-render at end if you prefer
Kino.Frame.render(form_frame, Kino.nothing())
user_text = String.trim(ev.data.prompt || "")
Kino.Frame.render(user_output, Kino.Markdown.new("**User**:\n\n" <> (user_text == "" && "_(empty)_" || user_text)))
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Assistant**:\n\n"))
# 1) Build context from previous cells
base_ctx =
case {session_id, current_cell_id} do
{sid, cid} when is_binary(sid) and is_binary(cid) ->
Kino.PromptBuddy.build_messages(sid, cid)
_ ->
[]
end
messages = base_ctx ++ [ReqLLM.Context.user(user_text)]
# Stream
Task.start(fn ->
case ReqLLM.stream_text(model, messages) do
{:ok, response} ->
{final, _count} =
ReqLLM.StreamResponse.tokens(response)
|> Enum.reduce({"", 0}, fn token, {acc, n} ->
new = acc <> token
n2 = n + 1
if rem(n2, n_every) == 0 do
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Assistant**:\n\n" <> new))
end
{new, n2}
end)
# Final flush
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Assistant**:\n\n" <> final <> " ✅")
)
{:error, err} ->
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Error**:\n\n```\n" <> inspect(err, pretty: true) <> "\n```")
)
end
end)
end)
grid([
form_frame,
user_output,
assistant_output
])
# ---------- /PromptBuddy UI ----------
end
|> Kino.SmartCell.quoted_to_string()
end
asset "main.js" do
"""
export function init(ctx, _payload) {
// Capture session id so we can retrieve previous cells
ctx.pushEvent("set_session_id", document.baseURI);
}
"""
end
end
Kino.SmartCell.register(Kino.PromptBuddy):ok
model = "openrouter:anthropic/claude-sonnet-4.5"
n_every = 24
session_id = "356mdgtglekdibxpicxgpgd45ila3nu64y767qmuu4lx4djt"
current_cell_id = Kino.PromptBuddy.get_current_cell_id()
import Kino.Shorts
form =
Kino.Control.form([prompt: Kino.Input.textarea("Prompt", default: "")], submit: "Send")
form_frame = frame()
user_output = frame()
assistant_output = frame()
Kino.Frame.render(form_frame, form)
Kino.listen(form, fn ev ->
Kino.Frame.render(form_frame, Kino.nothing())
user_text = String.trim(ev.data.prompt || "")
Kino.Frame.render(
user_output,
Kino.Markdown.new("**User**:\n\n" <> ((user_text == "" && "_(empty)_") || user_text))
)
Kino.Frame.render(assistant_output, Kino.Markdown.new("**Assistant**:\n\n"))
base_ctx =
case {session_id, current_cell_id} do
{sid, cid} when is_binary(sid) and is_binary(cid) ->
Kino.PromptBuddy.build_messages(sid, cid)
_ ->
[]
end
messages = base_ctx ++ [ReqLLM.Context.user(user_text)]
Task.start(fn ->
case ReqLLM.stream_text(model, messages) do
{:ok, response} ->
{final, _count} =
ReqLLM.StreamResponse.tokens(response)
|> Enum.reduce({"", 0}, fn token, {acc, n} ->
new = acc <> token
n2 = n + 1
if rem(n2, n_every) == 0 do
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Assistant**:\n\n" <> new)
)
end
{new, n2}
end)
Kino.Frame.render(
assistant_output,
Kino.Markdown.new("**Assistant**:\n\n" <> final <> " ✅")
)
{:error, err} ->
Kino.Frame.render(
assistant_output,
Kino.Markdown.new(
"**Error**:\n\n```\n" <> inspect(err, pretty: true) <> "\n```"
)
)
end
end)
end)
grid([form_frame, user_output, assistant_output])