Skip to content

Commit

Permalink
feat: better workbench
Browse files Browse the repository at this point in the history
  • Loading branch information
kieranklaassen committed Aug 15, 2024
1 parent b180680 commit ef373c1
Show file tree
Hide file tree
Showing 14 changed files with 524 additions and 135 deletions.
58 changes: 53 additions & 5 deletions app/controllers/leva/workbench_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,48 @@

module Leva
class WorkbenchController < ApplicationController
before_action :set_prompt, only: [:index, :edit, :update]
before_action :load_evaluators, only: [:index]
before_action :load_predefined_prompts, only: [:new, :create]

# GET /workbench
# @return [void]
def index
@prompts = Prompt.all
@selected_prompt = Prompt.first || Prompt.create!(name: "Test Prompt", version: 1, system_prompt: "You are a helpful assistant.", user_prompt: "Hello, how can I help you today?")
@evaluators = ['Evaluator 1', 'Evaluator 2', 'Evaluator 3']
@selected_prompt = @prompt || Prompt.first
end

# GET /workbench/new
# @return [void]
def new
@experiment = Experiment.new
@prompt = Prompt.new
end

# POST /workbench
# @return [void]
def create
@prompt = Prompt.new(prompt_params)
if @prompt.save
redirect_to workbench_index_path(prompt_id: @prompt.id), notice: 'Prompt was successfully created.'
else
render :new
end
end

# GET /workbench/1
# @return [void]
def show
@experiment = Experiment.find(params[:id])
def edit
end

# PATCH/PUT /workbench/1
# @return [void]
def update
@prompt = Prompt.find(params[:id])
if @prompt.update(prompt_params)
render json: { status: 'success', message: 'Prompt updated successfully' }
else
render json: { status: 'error', errors: @prompt.errors.full_messages }, status: :unprocessable_entity
end
end

def run
Expand All @@ -36,5 +60,29 @@ def run_evaluator
# Implement the logic for running a single evaluator
redirect_to workbench_index_path, notice: 'Evaluator run successfully'
end

private

def set_prompt
@prompt = params[:prompt_id] ? Prompt.find(params[:prompt_id]) : Prompt.first
end

def prompt_params
params.require(:prompt).permit(:name, :system_prompt, :user_prompt, :version)
end

def load_evaluators
@evaluators = Dir[Rails.root.join('app', 'evals', '*.rb')].map do |file|
File.basename(file, '.rb').camelize.constantize
end.select { |klass| klass < Leva::BaseEval }
end

def load_predefined_prompts
@predefined_prompts = Dir.glob(Rails.root.join('app', 'prompts', '*.md')).map do |file|
name = File.basename(file, '.md').titleize
content = File.read(file)
[name, content]
end
end
end
end
45 changes: 45 additions & 0 deletions app/javascript/controllers/prompt_form_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["form"];

autoSave() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.submitForm();
}, 500);
}

submitForm() {
const form = this.element;
const formData = new FormData(form);

fetch(form.action, {
method: form.method,
body: formData,
headers: {
Accept: "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
},
})
.then((response) => response.json())
.then((data) => {
const statusElement = document.getElementById("form-status");
if (data.status === "success") {
statusElement.textContent = "Changes saved successfully";
statusElement.classList.add("text-green-500");
statusElement.classList.remove("text-red-500");
} else {
statusElement.textContent = `Error: ${data.errors.join(", ")}`;
statusElement.classList.add("text-red-500");
statusElement.classList.remove("text-green-500");
}
setTimeout(() => {
statusElement.textContent = "";
}, 3000);
})
.catch((error) => {
console.error("Error:", error);
});
}
}
31 changes: 31 additions & 0 deletions app/javascript/controllers/prompt_selector_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["userPromptField"];

toggleUserPrompt(event) {
const selectedFile = event.target.value;
if (selectedFile) {
this.userPromptFieldTarget.style.display = "none";
this.loadPredefinedPrompt(selectedFile);
} else {
this.userPromptFieldTarget.style.display = "block";
this.clearUserPrompt();
}
}

loadPredefinedPrompt(file) {
fetch(file)
.then((response) => response.text())
.then((content) => {
const userPromptTextarea = this.userPromptFieldTarget.querySelector("textarea");
userPromptTextarea.value = content;
})
.catch((error) => console.error("Error loading predefined prompt:", error));
}

clearUserPrompt() {
const userPromptTextarea = this.userPromptFieldTarget.querySelector("textarea");
userPromptTextarea.value = "";
}
}
7 changes: 7 additions & 0 deletions app/models/leva/base_eval.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Leva
class BaseEval
def evaluate(prediction, text_content)
raise NotImplementedError, "Subclasses must implement the 'evaluate' method"
end
end
end
5 changes: 5 additions & 0 deletions app/views/leva/workbench/_evaluation_area.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="bg-gray-800 rounded-lg shadow-lg p-6">
<h3 class="text-xl font-semibold mb-4 text-indigo-300">Evaluation Results</h3>
<!-- Add evaluation results display here -->
<p class="text-gray-400">No evaluation results available yet. Run an evaluation to see results.</p>
</div>
107 changes: 107 additions & 0 deletions app/views/leva/workbench/_prompt_content.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<div class="flex-1 overflow-y-auto p-6 space-y-6" data-controller="prompt-autosave" data-prompt-autosave-url-value="<%= workbench_path(@selected_prompt) %>">
<!-- System Prompt -->
<div class="bg-gray-900 p-5 rounded-lg shadow-lg">
<h2 class="text-sm font-semibold mb-3 text-indigo-400">SYSTEM PROMPT</h2>
<textarea
class="w-full bg-gray-800 text-white p-3 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none min-h-[100px] overflow-hidden resize-none"
name="prompt[system_prompt]"
data-prompt-autosave-target="input"
data-action="input->prompt-autosave#debouncedSave"
><%= @selected_prompt.system_prompt %></textarea>
</div>
<!-- User Message -->
<div class="bg-gray-900 p-5 rounded-lg shadow-lg">
<h2 class="text-sm font-semibold mb-3 text-indigo-400">USER</h2>
<textarea
class="w-full bg-gray-800 text-white p-3 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none min-h-[200px] overflow-hidden resize-none"
name="prompt[user_prompt]"
data-prompt-autosave-target="input"
data-action="input->prompt-autosave#debouncedSave"
><%= @selected_prompt.user_prompt %></textarea>
</div>
<div class="text-sm text-center" data-prompt-autosave-target="status"></div>
</div>
<script>
(() => {
const application = Stimulus.Application.start()

application.register("prompt-autosave", class extends Stimulus.Controller {
static targets = ["input", "status"]
static values = { url: String }

connect() {
this.debouncedSave = this.debounce(this.save.bind(this), 1000)
this.adjustTextareaHeight()
this.inputTargets.forEach(input => {
input.addEventListener('input', () => this.adjustTextareaHeight(input))
})
}

adjustTextareaHeight(textarea = null) {
const textareas = textarea ? [textarea] : this.inputTargets
textareas.forEach(ta => {
ta.style.height = 'auto'
ta.style.height = ta.scrollHeight + 'px'
})
}

debouncedSave() {
this.debouncedSave()
}

save() {
const data = new FormData()
this.inputTargets.forEach(input => {
data.append(input.name, input.value)
})

this.statusTarget.textContent = "Saving..."
this.statusTarget.classList.add("text-yellow-500")

fetch(this.urlValue, {
method: 'PATCH',
body: data,
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
this.statusTarget.textContent = "Changes saved successfully"
this.statusTarget.classList.remove("text-yellow-500")
this.statusTarget.classList.add("text-green-500")
} else {
this.statusTarget.textContent = `Error: ${data.errors.join(", ")}`
this.statusTarget.classList.remove("text-yellow-500")
this.statusTarget.classList.add("text-red-500")
}
setTimeout(() => {
this.statusTarget.textContent = ""
this.statusTarget.classList.remove("text-green-500", "text-red-500")
}, 3000)
})
.catch(error => {
console.error('Error:', error)
this.statusTarget.textContent = "Error saving changes"
this.statusTarget.classList.remove("text-yellow-500")
this.statusTarget.classList.add("text-red-500")
})
}

debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
})
})()
</script>
89 changes: 89 additions & 0 deletions app/views/leva/workbench/_prompt_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<%= form_with(model: prompt, url: workbench_path(prompt), method: :patch, local: false, class: "bg-gray-800 rounded-lg shadow-lg p-6", data: { controller: "prompt-form" }) do |form| %>
<div class="mb-4">
<%= form.label :name, class: "block text-sm font-semibold mb-2 text-indigo-300" %>
<%= form.text_field :name, class: "w-full bg-gray-700 text-white p-3 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none", data: { action: "input->prompt-form#autoSave" } %>
</div>
<div class="mb-4">
<%= form.label :version, class: "block text-sm font-semibold mb-2 text-indigo-300" %>
<%= form.number_field :version, class: "w-full bg-gray-700 text-white p-3 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none", data: { action: "input->prompt-form#autoSave" } %>
</div>
<div class="mb-4">
<%= form.label :system_prompt, class: "block text-sm font-semibold mb-2 text-indigo-300" %>
<%= form.text_area :system_prompt, rows: 5, class: "w-full bg-gray-700 text-white p-3 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none", data: { action: "input->prompt-form#autoSave" } %>
</div>
<div class="mb-4">
<%= form.label :user_prompt, class: "block text-sm font-semibold mb-2 text-indigo-300" %>
<%= form.text_area :user_prompt, rows: 5, class: "w-full bg-gray-700 text-white p-3 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none", data: { action: "input->prompt-form#autoSave" } %>
</div>
<div id="form-status" class="mb-4 text-center" data-prompt-form-target="status"></div>
<% end %>
<script>
(() => {
const application = Stimulus.Application.start()

application.register("prompt-form", class extends Stimulus.Controller {
static targets = ["status"]

connect() {
this.timeout = null
this.debounceTime = 1000 // 1 second debounce
this.lastSavedContent = this.formContent()
}

autoSave() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
const currentContent = this.formContent()
if (currentContent !== this.lastSavedContent) {
this.submitForm()
} else {
this.showStatus("No changes to save", "text-gray-500")
}
}, this.debounceTime)
}

submitForm() {
const form = this.element
const formData = new FormData(form)

this.showStatus("Saving...", "text-yellow-500")

fetch(form.action, {
method: form.method,
body: formData,
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
},
})
.then(response => response.json())
.then(data => {
if (data.status === "success") {
this.showStatus("Changes saved successfully", "text-green-500")
this.lastSavedContent = this.formContent()
} else {
this.showStatus(`Error: ${data.errors.join(", ")}`, "text-red-500")
}
})
.catch(error => {
console.error("Error:", error)
this.showStatus("Error saving changes", "text-red-500")
})
}

showStatus(message, className) {
this.statusTarget.textContent = message
this.statusTarget.className = `mb-4 text-center ${className}`
setTimeout(() => {
this.statusTarget.textContent = ""
this.statusTarget.className = "mb-4 text-center"
}, 3000)
}

formContent() {
return JSON.stringify(Object.fromEntries(new FormData(this.element)))
}
})
})()
</script>
Loading

0 comments on commit ef373c1

Please sign in to comment.