CinEx
Mix.install([
{:kino, "~> 0.12.3"},
{:instructor, github: "acalejos/instructor_ex"},
{:erlexec, "~> 2.0"},
{:exterval, "~> 0.2.0"},
{:req, "~> 0.4.0"}
])
Upload Struct
defmodule Upload do
defstruct [:filename, :path]
@video_types [:mp4, :ogg, :avi, :wmv, :mov]
@audio_types [:wav, :mp3, :mpeg]
@image_types [:jpeg, :jpg, :png, :gif, :svg, :pixel]
defguard is_audio(ext) when ext in @audio_types
defguard is_video(ext) when ext in @video_types
defguard is_image(ext) when ext in @image_types
defguard is_valid_upload(ext) when is_audio(ext) or is_video(ext) or is_image(ext)
def accepted_types, do: @audio_types ++ @video_types ++ @image_types
defp to_existing_atom(str) do
try do
{:ok, String.to_existing_atom(str)}
rescue
_ in ArgumentError ->
{:error, "#{inspect(str)} is not an existing atom"}
_e ->
{:error, "Unknown Error ocurred in `String.to_existing_atom/1`"}
end
end
def ext_type(filename) do
with <<"."::utf8, rest::binary>> <- Path.extname(filename),
{:ok, ext} <- to_existing_atom(rest) do
ext
end
end
def to_kino(upload = %__MODULE__{path: path}) do
content = File.read!(upload.path)
case ext_type(path) do
ext when is_audio(ext) ->
Kino.Audio.new(content, ext)
ext when is_video(ext) ->
Kino.Video.new(content, ext)
ext when is_image(ext) ->
Kino.Image.new(content, ext)
end
end
def new(filename, path) do
%__MODULE__{filename: filename, path: path}
end
def generate_temp_filename(extension \\ "mp4") do
random_string = :crypto.strong_rand_bytes(8) |> Base.encode16()
temp_dir = System.tmp_dir!()
Path.join(temp_dir, "temp_#{random_string}.#{extension}")
end
end
Setup State Management
defmodule Models do
@oai_models Req.get!("https://api.openai.com/v1/models",
auth: {:bearer, System.fetch_env!("LB_OPENAI_TOKEN")}
)
|> Map.get(:body)
|> Map.get("data")
|> Enum.filter(fn entry -> Map.get(entry, "id") |> String.contains?("gpt") end)
|> Enum.sort_by(&Map.get(&1, "created"), :desc)
|> Enum.map(fn entry ->
model = Map.get(entry, "id")
{model, model}
end)
@ollama_models Req.get!("http://localhost:11434/api/tags")
|> Map.get(:body)
|> Map.get("models")
|> Enum.sort_by(fn entry ->
{:ok, datetime, _offset} =
entry |> Map.get("modified_at") |> DateTime.from_iso8601()
datetime
end)
|> Enum.map(fn entry ->
name = Map.get(entry, "name")
{name, name}
end)
# There are many different ways to authenticate. This uses API Keys
# See https://cloud.google.com/docs/authentication/api-keys
# We filter for only Gemini 1.5 models since those are the only to support JSON mode in the API
@gemini_models Req.get!("https://generativelanguage.googleapis.com/v1beta/models",
headers: %{"x-goog-api-key" => System.fetch_env!("LB_GOOGLE_GEMINI_API_KEY")}
)
|> Map.get(:body)
|> Map.get("models")
|> Enum.filter(fn entry ->
Map.get(entry, "name") |> String.contains?("gemini-1.5")
end)
|> Enum.map(fn entry ->
{Map.get(entry, "name"), Map.get(entry, "displayName")}
end)
def oai_models, do: @oai_models
def ollama_models, do: @ollama_models
def gemini_models, do: @gemini_models
end
defmodule FormState do
use Agent
@openai_config [
http_options: [receive_timeout: 10 * 60 * 1000],
api_key: System.fetch_env!("LB_OPENAI_TOKEN"),
api_url: "https://api.openai.com",
adapter: Instructor.Adapters.OpenAI
]
@ollama_config [
http_options: [receive_timeout: 10 * 60 * 1000],
api_key: "ollama",
api_url: "http://localhost:11434",
adapter: Instructor.Adapters.OpenAI
]
@gemini_config [
api_version: :v1beta,
api_url: "https://generativelanguage.googleapis.com/",
http_options: [receive_timeout: 60_000],
api_key: System.fetch_env!("LB_GOOGLE_GEMINI_API_KEY"),
adapter: Instructor.Adapters.Gemini
]
def start_link(_init) do
Agent.start_link(
fn ->
%{
config: @openai_config,
provider: :openai,
prompt: "",
retries: 2,
debug: false,
explain_outputs: true,
model: Models.oai_models() |> hd() |> elem(0),
adapter: Application.get_env(:instructor, :adapter, Instructor.Adapters.OpenAI)
}
end,
name: __MODULE__
)
end
def update(:provider, :openai) do
Agent.update(__MODULE__, fn state ->
state
|> Map.put(:config, @openai_config)
|> Map.put(:provider, :openai)
end)
end
def update(:provider, :ollama) do
Agent.update(__MODULE__, fn state ->
state
|> Map.put(:config, @ollama_config)
|> Map.put(:provider, :ollama)
end)
end
def update(:provider, :gemini) do
Agent.update(__MODULE__, fn state ->
state
|> Map.put(:config, @gemini_config)
|> Map.put(:provider, :gemini)
end)
end
def update(key, value) do
Agent.update(__MODULE__, fn state -> Map.put(state, key, value) end)
end
def get(key) do
Agent.get(__MODULE__, fn state -> Map.get(state, key) end)
end
end
defmodule EditHistory do
use Agent
def start_link(_init) do
Agent.start_link(fn -> :queue.new() end, name: __MODULE__)
end
def push(%Upload{} = upload, prompt \\ nil) do
Agent.update(__MODULE__, fn history ->
:queue.snoc(history, {upload, prompt})
end)
end
def undo_edit do
Agent.get_and_update(__MODULE__, fn history ->
popped = :queue.liat(history)
{:queue.last(popped), popped}
end)
end
def current do
Agent.get(__MODULE__, fn history ->
:queue.last(history)
end)
end
def original do
Agent.get(__MODULE__, fn history ->
:queue.head(history)
end)
end
def previous_edit do
Agent.get(__MODULE__, fn history ->
popped = :queue.liat(history)
unless :queue.is_empty(popped) do
:queue.last(popped)
else
nil
end
end)
end
def reset do
Agent.get_and_update(__MODULE__, fn history ->
original = :queue.head(history)
{original, :queue.from_list([original])}
end)
end
end
Enum.each([EditHistory, FormState], &Kino.start_child!/1)
Setup Boilerplate
defmodule Boilerplate do
def placeholder,
do: """
Video Preview Placeholder with Spinner
.video-preview-placeholder {
width: 100%;
max-width: 640px;
height: 0;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
border: 2px dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
color: #666;
font-size: 20px;
text-align: center;
position: relative;
box-sizing: border-box;
margin: auto;
}
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 2s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.message {
font-size: 16px;
color: #666;
}
<%= if show_spinner do %>
<% end %>
<%= message %>
"""
def log_template,
do: """
Log Level Message Box
.message-box {
width: 100%;
border: 2px solid;
padding: 20px;
box-sizing: border-box;
margin: 20px 0;
border-radius: 5px;
font-size: 18px;
text-align: left;
}
.message-box.error {
border-color: #f44336;
background-color: #fdecea;
color: #f44336;
}
.message-box.success {
border-color: #4caf50;
background-color: #e8f5e9;
color: #4caf50;
}
.message-box.info {
border-color: #2196f3;
background-color: #e3f2fd;
color: #2196f3;
}
<%= message %>
"""
def stdout_template,
do: """
<%= device %>
body {
background-color: #1e1e1e;
color: #c5c8c6;
font-family: "Courier New", Courier, monospace;
margin: 0;
padding: 20px 20px 20px 5px;
}
.container {
border: 1px solid #444;
border-radius: 5px;
overflow: hidden;
}
.header {
background-color: #444;
color: #c5c8c6;
padding: 10px;
font-weight: bold;
text-transform: uppercase;
}
.output {
background-color: #1d1f21;
border-left: 4px solid <%= border_color %>;
padding: 12px 12px 12px 5px;
font-size: 16px;
color: #c5c8c6;
white-space: pre-wrap;
word-break: break-all;
}
<%= device %>
<%= output %>
"""
def make_stdout(output, device, border_color \\ "gray") do
Kino.HTML.new(EEx.eval_string(stdout_template(), binding()))
end
def make_log(message, level) do
Kino.HTML.new(EEx.eval_string(log_template(), binding()))
end
end
Setup Form
original = Kino.Frame.new()
prompt = Kino.Input.textarea("Prompt")
upload = Kino.Input.file("Upload", accept: Upload.accepted_types())
errors = Kino.Frame.new(placeholder: false)
submit_button = Kino.Control.button("Run!")
submit_frame = Kino.Frame.new(placeholder: false)
undo_frame = Kino.Frame.new(placeholder: false)
reset_frame = Kino.Frame.new(placeholder: false)
undo_button = Kino.Control.button("Undo")
reset_button = Kino.Control.button("Reset")
output = Kino.Frame.new(placeholder: false)
logs = Kino.Frame.new(placeholder: false)
debug_checkbox = Kino.Input.checkbox("Verbose Mode")
debug_frame = Kino.Frame.new(placeholder: false)
explain_checkbox = Kino.Input.checkbox("Explain Outputs", default: true)
explain_frame = Kino.Frame.new(placeholder: false)
retries = Kino.Input.number("# Retries", default: 2)
provider =
Kino.Input.select("Provider", [{:openai, "OpenAI"}, {:gemini, "Gemini"}, {:ollama, "Ollama"}])
models_frame = Kino.Frame.new(placeholder: false)
oai_select = Kino.Input.select("Model", Models.oai_models())
gemini_select = Kino.Input.select("Model", Models.gemini_models())
ollama_select =
Kino.Input.select(
"Model",
if(Models.ollama_models() == [],
do: [{nil, "Pull Local Model to Use Ollama"}],
else: Models.ollama_models()
)
)
Kino.Frame.render(models_frame, oai_select)
Kino.Frame.render(
original,
Kino.HTML.new(
EEx.eval_string(Boilerplate.placeholder(),
message: "Upload Media to Get Started",
show_spinner: false
)
)
)
model_info = Kino.Layout.grid([provider, models_frame], columns: 2)
inputs = Kino.Layout.grid([prompt, retries], columns: 2, gap: 10)
buttons =
Kino.Layout.grid([submit_frame, undo_frame, reset_frame, explain_frame, debug_frame],
columns: 7,
gap: 1
)
Kino.Layout.grid([original, model_info, inputs, upload, buttons, output, logs])
FFMPEG Instructions
defmodule Alfred do
use Ecto.Schema
use Instructor.Validator
import Ecto.Changeset
import Exterval
@confidence_interval ~i<[0,10]//0.5>
@system_prompt """
You are the companion Agent to another Agent whose job is to product execve-styled arguments
for programs given a specific prompt. Your job is to interpret and explain the output
of the command after it has been run. You will be given the prompt / task that originally
generated the command, then you will be given the command that was run, along with the
output that was generated. You do not need to re-explain what the task was or regurgitate
what the command was. You only need to explain what the output means within the context
of the task. If the task / prompt was a question, you should determine whether the provided
output directly answers the question and if it does not you should answer it based on the
output. If the output is not relevant to the prompt this should also be noted.
You will also provide a confidence score about how confident you are about the above explanation.
The confidence score is separate from the explanation.
"""
@primary_key false
@doc """
## Field Descriptions:
- explanation: Explanation of the output given the context of the task and command that was run
- confidence: Rating from 0 to 10 in increments of 0.5 of how confident you are in your answer,
with higher scores being more confident.
"""
embedded_schema do
field(:explanation, :string)
field(:confidence, :float)
end
@impl true
def validate_changeset(changeset) do
changeset
|> validate_inclusion(:confidence, @confidence_interval)
end
def execute(model, config, prompt, command, retries, outputs \\ [stdout: nil, stderr: nil]) do
Instructor.chat_completion(
[
mode: :json,
model: model,
response_model: __MODULE__,
max_retries: retries,
messages:
[
%{
role: "system",
content: @system_prompt
},
%{
role: "user",
content: """
Here's the prompt that generated the command: #{inspect(prompt)}
"""
},
%{
role: "user",
content: """
Here's command: #{inspect(command)}
"""
},
Keyword.get(outputs, :stdout) &&
%{
role: "user",
content: """
stdout: #{inspect(Keyword.fetch!(outputs, :stdout))}
"""
},
Keyword.get(outputs, :stderr) &&
%{
role: "user",
content: """
stderr: #{inspect(Keyword.fetch!(outputs, :stderr))}
"""
}
]
|> Enum.filter(& &1)
],
config
)
end
end
defmodule AutoFfmpeg do
import Ecto.Changeset
use Ecto.Schema
use Instructor.Validator
@system_prompt """
You are a multimedia editor and your job is to receive tasks for multimedia editing and use
the programs available to you (and only those) to complete the tasks. You will return arguments
to be passed to the program assuming that the input file(s) has already been passed. You do not need to
call the binary itself, you are only in charge of generating all subsequent
arugments after inputs have been passed. Assume the output file path will be appended
after the arguments you provide.
You have access to the following programs: ffmpeg and ffprobe
So assume the command already is composed of something like
`ffmpeg -i input_file_path [..., args, ...] output_file_path` and you then pass arugments
to complete the given task. You will also be provided the input file for context, but you
should not include inputs in your arguments. Use the given file extension to determine how
to form your arugments. You will also provide the output file
extension / file type, since depending on the task it could differ from the input type. If the
given task does not result in an operation that writes to a file, (eg. asking for timestamps
where it is silent would result in writing to stdout), the extension would be `null`.
If the command is such that it will output to stdout, you should output as JSON when
possible.
"""
@doc """
## Field Descriptions:
- program: the executable program to call
- arguments: execve-formatted arguments for the command
- output_ext: The extension (filetype) of the outputted file
"""
@primary_key false
embedded_schema do
field(:program, Ecto.Enum, values: [:ffmpeg, :ffprobe])
field(:arguments, {:array, :string})
field(:output_ext, Ecto.Enum,
values: [
:mp4,
:ogg,
:avi,
:wmv,
:mov,
:wav,
:mp3,
:mpeg,
:jpeg,
:jpg,
:png,
:gif,
:svg,
:pixel,
:null
]
)
field(:output_path, :string, virtual: true)
end
@impl true
def validate_changeset(
changeset,
context
) do
changeset
|> validate_required([:program, :arguments, :output_ext])
|> validate_exclusion(:arguments, ~w(-i))
|> validate_command(context)
end
defp validate_command(%Ecto.Changeset{valid?: false} = changeset, _context), do: changeset
defp validate_command(
changeset,
%{
upload_path: upload_path,
debug: debug,
debug_frame: debug_frame,
output_frame: output_frame,
prompt: prompt,
explain: explain,
retries: retries,
model: model,
config: config
}
) do
program = get_field(changeset, :program)
program_args = get_field(changeset, :arguments)
input_args = ["-i", upload_path]
output_ext = get_field(changeset, :output_ext)
# Only perform command if arguments pass validation
# Otherwise skip running command and return arg errors
output_args =
cond do
program == :ffprobe ->
[]
output_ext == :null ->
["-f", "null", "-"]
true ->
[Upload.generate_temp_filename(Atom.to_string(output_ext))]
end
command =
Enum.join([Atom.to_string(program) | input_args ++ program_args ++ output_args], " ")
if debug do
message = """
Command:
#{command}
"""
Kino.Frame.append(
debug_frame,
Boilerplate.make_log(
message,
:info
)
)
end
case :exec.run(command, [
:sync,
:stdout,
:stderr
]) do
{:ok, result} when is_list(result) ->
outputs =
[:stdout, :stderr]
|> Enum.map(fn device ->
if Keyword.has_key?(result, device) do
output = Enum.join(Keyword.fetch!(result, device), "")
Kino.Frame.append(output_frame, Boilerplate.make_stdout(output, device))
{device, output}
else
{device, nil}
end
end)
if explain do
case Alfred.execute(model, config, prompt, command, retries, outputs) do
{:ok, %Alfred{explanation: explanation, confidence: confidence}} ->
Kino.Frame.append(
output_frame,
Boilerplate.make_stdout(
"Explanation: #{explanation}\n\nConfidence: #{confidence}",
:alfred,
"green"
)
)
{:error,
%Ecto.Changeset{
errors: [
explanation: {error, _extras}
],
valid?: false
}} ->
Kino.Frame.append(
debug_frame,
Boilerplate.make_log("Trouble providing explanation: #{inspect(error)}", :error)
)
end
end
if program == :ffmpeg && output_ext != :null do
[output_path] = output_args
put_change(changeset, :output_path, output_path)
else
changeset
end
{:error, result} when is_list(result) ->
debug &&
Kino.Frame.append(
debug_frame,
Boilerplate.make_log("Something Went Wrong! Retrying...", :error)
)
error =
cond do
Keyword.has_key?(result, :stderr) ->
Keyword.fetch!(result, :stderr) |> Enum.join("")
Keyword.has_key?(result, :stdout) ->
Keyword.fetch!(result, :stdout) |> Enum.join("")
Keyword.has_key?(result, :exit_signal) ->
"Error resulted in exit code #{Keyword.fetch!(result, :exit_signal)}"
true ->
"Unexpected error occurred!"
end
add_error(
changeset,
:arguments,
error,
status: Keyword.get(result, :exit_status)
)
end
end
def execute(model, config, prompt, %{upload_path: upload_path} = context, retries) do
Instructor.chat_completion(
[
mode: :json,
model: model,
validation_context:
Map.merge(context, %{
prompt: prompt,
retries: retries,
model: model,
config: config
}),
response_model: __MODULE__,
max_retries: retries,
messages: [
%{
role: "system",
content: @system_prompt
},
%{
role: "user",
content: """
Here's the editing task: #{inspect(prompt)}
"""
},
%{
role: "user",
content: """
Here's input file type: #{inspect(Upload.ext_type(upload_path))}
"""
}
]
],
config
)
end
end
Listeners
import Upload
[
upload: upload,
submit: submit_button,
reset: reset_button,
undo: undo_button,
prompt: prompt,
debug: debug_checkbox,
retries: retries,
explain: explain_checkbox,
provider: provider,
oai_select: oai_select,
ollama_select: ollama_select,
gemini_select: gemini_select
]
|> Kino.Control.tagged_stream()
|> Kino.listen(fn
{model_select, %{type: :change, value: value}}
when model_select in [:oai_select, :ollama_select, :gemini_select] ->
FormState.update(:model, value)
{:provider, %{type: :change, value: value}} ->
Kino.Frame.clear(logs)
current_provider = FormState.get(:provider)
unless value == current_provider do
case value do
:ollama ->
if Models.ollama_models() == [] do
Kino.Frame.append(
logs,
Boilerplate.make_log(
"You must have local models to use Ollama. Pull a model then rerun this notebook.",
:error
)
)
end
Kino.Frame.render(models_frame, ollama_select)
FormState.update(:provider, value)
FormState.update(:model, Models.ollama_models() |> hd() |> elem(0))
:openai ->
Kino.Frame.render(models_frame, oai_select)
FormState.update(:provider, value)
FormState.update(:model, Models.oai_models() |> hd() |> elem(0))
:gemini ->
Kino.Frame.render(models_frame, gemini_select)
FormState.update(:provider, value)
FormState.update(:model, Models.gemini_models() |> hd() |> elem(0))
_ ->
Kino.Frame.append(
logs,
Boilerplate.make_log(
"Bad provider selected. Only OpenAI and Ollama are currently supported.",
:error
)
)
end
end
{:explain, %{type: :change, value: value}} ->
FormState.update(:explain_outputs, value)
{:retries, %{type: :change, value: value}} ->
FormState.update(:retries, value)
{:debug, %{type: :change, value: value}} ->
FormState.update(:debug, value)
{:prompt, %{type: :change, value: prompt}} ->
FormState.update(:prompt, prompt)
{:upload,
%{
type: :change,
value: %{
file_ref: file_ref,
client_name: filename
}
}} ->
Kino.Frame.clear(logs)
Kino.Frame.clear(output)
ext_type = Upload.ext_type(filename)
unless is_valid_upload(ext_type) do
Kino.Frame.render(
logs,
Boilerplate.make_log(
"File must be of one of the following types: #{inspect(Upload.accepted_types())}",
:error
)
)
else
file_path =
file_ref
|> Kino.Input.file_path()
tmp_path = Upload.generate_temp_filename(ext_type)
_bytes_copied = File.copy!(file_path, tmp_path)
upload = Upload.new(filename, tmp_path)
Upload.to_kino(upload) |> then(&Kino.Frame.render(original, &1))
EditHistory.push(upload)
Kino.Frame.render(debug_frame, debug_checkbox)
Kino.Frame.render(explain_frame, explain_checkbox)
Kino.Frame.render(submit_frame, submit_button)
Kino.Frame.clear(undo_frame)
Kino.Frame.clear(reset_frame)
end
{:submit, %{type: :click}} ->
Kino.Frame.clear(logs)
Kino.Frame.clear(output)
prompt = FormState.get(:prompt) |> String.trim()
if prompt == "" do
Kino.Frame.append(logs, Boilerplate.make_log("Prompt cannot be empty!", :error))
else
Kino.Frame.render(
original,
Kino.HTML.new(
EEx.eval_string(Boilerplate.placeholder(), message: "Working...", show_spinner: true)
)
)
{%Upload{} =
current_upload, _old_prompt} = EditHistory.current()
num_retries = FormState.get(:retries)
model = FormState.get(:model)
debug = FormState.get(:debug)
if debug do
message = """
Provider: #{FormState.get(:provider)}
Adapter: #{FormState.get(:config) |> Keyword.fetch!(:adapter)}
Model:
#{model}
Prompt: #{prompt}
"""
Kino.Frame.append(
logs,
Boilerplate.make_log(
message,
:info
)
)
end
case AutoFfmpeg.execute(
model,
FormState.get(:config),
prompt,
%{
upload_path: current_upload.path,
debug: debug,
debug_frame: logs,
output_frame: output,
explain: FormState.get(:explain_outputs)
},
num_retries
) do
{:ok, %AutoFfmpeg{output_path: output_path}} ->
FormState.get(:debug) &&
Kino.Frame.append(logs, Boilerplate.make_log("Success!", :success))
unless is_nil(output_path) do
new_upload = Upload.new(current_upload.filename, output_path)
EditHistory.push(new_upload, prompt)
Upload.to_kino(new_upload) |> then(&Kino.Frame.render(original, &1))
else
Upload.to_kino(current_upload) |> then(&Kino.Frame.render(original, &1))
end
Kino.Frame.render(undo_frame, undo_button)
Kino.Frame.render(reset_frame, reset_button)
{:error,
%Ecto.Changeset{
errors: errors,
valid?: false
}} ->
Upload.to_kino(current_upload) |> then(&Kino.Frame.render(original, &1))
Kino.Frame.append(
logs,
Boilerplate.make_log("Failed after #{num_retries} attempts!", :error)
)
Enum.each(errors, fn {field, error} ->
Kino.Frame.append(
logs,
Boilerplate.make_log("Error on field #{inspect(field)}: #{inspect(error)}", :error)
)
end)
{:error, <<"LLM Adapter Error: ", error::binary>>} ->
Upload.to_kino(current_upload) |> then(&Kino.Frame.render(original, &1))
{error, _binding} = error |> Code.eval_string()
Kino.Frame.append(
logs,
Boilerplate.make_log("Error! Reference the error below for details", :error)
)
Kino.Frame.append(logs, Kino.Tree.new(error))
{:error, <<"Invalid JSON returned from LLM: ", error::binary>>} ->
Upload.to_kino(current_upload) |> then(&Kino.Frame.render(original, &1))
Kino.Frame.append(logs, Boilerplate.make_log(error, :error))
end
end
{:reset, %{type: :click}} ->
{%Upload{} = original_upload, nil} = EditHistory.reset()
Upload.to_kino(original_upload) |> then(&Kino.Frame.render(original, &1))
Kino.Frame.clear(logs)
Kino.Frame.clear(output)
Kino.Frame.clear(reset_frame)
Kino.Frame.clear(undo_frame)
{:undo, %{type: :click}} ->
Kino.Frame.clear(logs)
Kino.Frame.clear(output)
case EditHistory.undo_edit() do
nil ->
Kino.Frame.append(logs, Kino.Text.new("Error! Cannot `Undo`. No previous edit."))
{%Upload{} = previous_upload, _previous_prompt} ->
Upload.to_kino(previous_upload) |> then(&Kino.Frame.render(original, &1))
Kino.Frame.clear(logs)
if EditHistory.previous_edit() == nil do
Kino.Frame.clear(reset_frame)
Kino.Frame.clear(undo_frame)
end
end
end)