feat(actions): add playground to test actions (#74)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-03-21 23:51:55 +01:00
committed by GitHub
parent b42ef27641
commit d689bb4331
5 changed files with 416 additions and 46 deletions

View File

@@ -1,6 +1,7 @@
package webui
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -9,8 +10,10 @@ import (
"time"
"github.com/mudler/LocalAgent/pkg/xlog"
"github.com/mudler/LocalAgent/services"
"github.com/mudler/LocalAgent/webui/types"
"github.com/mudler/LocalAgent/core/action"
"github.com/mudler/LocalAgent/core/agent"
"github.com/mudler/LocalAgent/core/sse"
"github.com/mudler/LocalAgent/core/state"
@@ -299,6 +302,47 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error {
}
}
func (a *App) ExecuteAction(pool *state.AgentPool) func(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error {
payload := struct {
Config map[string]string `json:"config"`
Params action.ActionParams `json:"params"`
}{}
if err := c.BodyParser(&payload); err != nil {
xlog.Error("Error parsing action payload", "error", err)
return errorJSONMessage(c, err.Error())
}
actionName := c.Params("name")
xlog.Debug("Executing action", "action", actionName, "config", payload.Config, "params", payload.Params)
a, err := services.Action(actionName, payload.Config, pool)
if err != nil {
xlog.Error("Error creating action", "error", err)
return errorJSONMessage(c, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Second)
defer cancel()
res, err := a.Run(ctx, payload.Params)
if err != nil {
xlog.Error("Error running action", "error", err)
return errorJSONMessage(c, err.Error())
}
xlog.Info("Action executed", "action", actionName, "result", res)
return c.JSON(res)
}
}
func (a *App) ListActions() func(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error {
return c.JSON(services.AvailableActions)
}
}
func (a *App) Responses(pool *state.AgentPool) func(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error {
var request types.RequestBody

View File

@@ -130,10 +130,17 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
})
})
webapp.Get("/actions-playground", func(c *fiber.Ctx) error {
return c.Render("views/actions", fiber.Map{})
})
// New API endpoints for getting and updating agent configuration
webapp.Get("/api/agent/:name/config", app.GetAgentConfig(pool))
webapp.Put("/api/agent/:name/config", app.UpdateAgentConfig(pool))
webapp.Post("/action/:name/run", app.ExecuteAction(pool))
webapp.Get("/actions", app.ListActions())
webapp.Post("/settings/import", app.ImportAgent(pool))
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
}

291
webui/views/actions.html Normal file
View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Actions Playground</title>
{{template "views/partials/header"}}
</head>
<body>
{{template "views/partials/menu"}}
<!-- Toast for notifications -->
<div id="toast" class="toast">
<span id="toast-message"></span>
</div>
<div class="container mx-auto">
<header class="text-center mb-8">
<h1 class="text-4xl md:text-6xl font-bold">Actions Playground</h1>
<p class="mt-4 text-gray-400">Test and execute actions directly from the UI</p>
</header>
<section class="section-box mb-8">
<h2 class="mb-4">Select an Action</h2>
<div class="mb-4">
<label for="action-select" class="block mb-2">Available Actions:</label>
<select id="action-select" class="w-full">
<option value="">-- Select an action --</option>
<!-- Actions will be loaded here -->
</select>
</div>
</section>
<section id="config-section" class="section-box mb-8 hidden">
<h2 class="mb-4">Action Configuration</h2>
<form id="action-form">
<div class="mb-6">
<label for="config-json" class="block mb-2">Configuration (JSON):</label>
<textarea id="config-json" class="w-full" rows="5" placeholder='{"key": "value"}'>{}</textarea>
<p class="text-xs text-gray-400 mt-1">Enter JSON configuration for the action</p>
</div>
<div class="mb-6">
<label for="params-json" class="block mb-2">Parameters (JSON):</label>
<textarea id="params-json" class="w-full" rows="5" placeholder='{"key": "value"}'>{}</textarea>
<p class="text-xs text-gray-400 mt-1">Enter JSON parameters for the action</p>
</div>
<div class="flex justify-end">
<button type="submit" class="action-btn start-btn">
<i class="fas fa-play"></i> Execute Action
</button>
</div>
</form>
</section>
<section id="results-section" class="section-box mb-8 hidden">
<h2 class="mb-4">Action Results</h2>
<div id="action-results">
<!-- Results will appear here -->
</div>
</section>
<footer class="text-center text-gray-500 text-sm mb-8">
<p>&copy; 2025 LocalAgent.</p>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load available actions
fetchActions();
// Handle action selection
document.getElementById('action-select').addEventListener('change', function() {
const actionId = this.value;
if (actionId) {
document.getElementById('config-section').classList.remove('hidden');
} else {
document.getElementById('config-section').classList.add('hidden');
}
// Hide results when changing actions
document.getElementById('results-section').classList.add('hidden');
});
// Handle form submission
document.getElementById('action-form').addEventListener('submit', function(e) {
e.preventDefault();
const actionId = document.getElementById('action-select').value;
if (actionId) {
executeAction(actionId);
} else {
showToast('Please select an action first', 'error');
}
});
});
function fetchActions() {
fetch('/actions')
.then(response => response.json())
.then(actions => {
const select = document.getElementById('action-select');
// Clear existing options except the first one
while (select.options.length > 1) {
select.remove(1);
}
if (actions.length === 0) {
const option = document.createElement('option');
option.text = 'No actions available';
option.disabled = true;
select.add(option);
return;
}
// Add options for each action
actions.forEach(actionId => {
const option = document.createElement('option');
option.value = actionId;
option.text = actionId; // Using actionId as display text
select.add(option);
});
})
.catch(error => {
console.error('Error fetching actions:', error);
showToast('Failed to load actions: ' + error.message, 'error');
const select = document.getElementById('action-select');
const option = document.createElement('option');
option.text = 'Error loading actions';
option.disabled = true;
// Clear existing options except the first one
while (select.options.length > 1) {
select.remove(1);
}
select.add(option);
});
}
function executeAction(actionId) {
// Get the JSON data from textareas
let config = {};
let params = {};
try {
const configText = document.getElementById('config-json').value.trim();
if (configText && configText !== '{}') {
config = JSON.parse(configText);
}
const paramsText = document.getElementById('params-json').value.trim();
if (paramsText && paramsText !== '{}') {
params = JSON.parse(paramsText);
}
} catch (error) {
showToast('Invalid JSON: ' + error.message, 'error');
return;
}
// Show the results section with loading indicator
const resultsSection = document.getElementById('results-section');
resultsSection.classList.remove('hidden');
const resultDiv = document.getElementById('action-results');
resultDiv.innerHTML = `
<div class="flex justify-center items-center py-8">
<div class="loader"></div>
</div>
`;
// Execute the action
fetch(`/action/${actionId}/run`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config: config,
params: params
})
})
.then(response => {
return response.json();
})
.then(result => {
if (result.error) {
throw new Error(result.error);
}
// Display the results
showActionResult(result);
showToast('Action executed successfully!', 'success');
})
.catch(error => {
resultDiv.innerHTML = `
<div class="alert alert-error" style="display: block;">
<i class="fas fa-exclamation-circle mr-2"></i> Error: ${error.message}
</div>
`;
showToast('Error executing action', 'error');
});
}
function showActionResult(result) {
const resultDiv = document.getElementById('action-results');
let html = '';
// Display result
if (result.Result) {
html += `
<div class="mb-4">
<h4 class="text-lg mb-2" style="color: var(--secondary);">Result:</h4>
<div class="code-terminal">
<pre>${escapeHtml(result.Result)}</pre>
</div>
</div>
`;
}
// Display metadata if available
if (result.Metadata && Object.keys(result.Metadata).length > 0) {
html += `
<div class="mb-4">
<h4 class="text-lg mb-2" style="color: var(--secondary);">Metadata:</h4>
<div class="code-terminal">
<pre>${escapeHtml(JSON.stringify(result.Metadata, null, 2))}</pre>
</div>
</div>
`;
}
if (!html) {
html = '<p class="text-gray-400">No results returned from the action.</p>';
}
resultDiv.innerHTML = html;
// Scroll to results
resultDiv.scrollIntoView({ behavior: 'smooth' });
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function showToast(message, type) {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toast-message');
toastMessage.textContent = message;
toast.className = 'toast toast-' + type;
toast.classList.add('toast-visible');
setTimeout(() => {
toast.classList.remove('toast-visible');
}, 3000);
}
</script>
<style>
.loader {
width: 48px;
height: 48px;
border: 5px solid var(--tertiary);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.code-terminal {
margin-top: 0;
}
</style>
</body>
</html>

View File

@@ -33,6 +33,12 @@
<span class="absolute bottom-0 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
style="background: linear-gradient(90deg, var(--secondary), var(--tertiary));"></span>
</a>
<a href="/actions-playground" class="px-3 py-2 rounded-md text-lg font-medium text-gray-400 hover:bg-gray-800 transition duration-300 relative overflow-hidden group">
<i class="fas fa-bolt mr-2"></i> Actions Playground
<!-- Underline animation -->
<span class="absolute bottom-0 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
style="background: linear-gradient(90deg, var(--tertiary), var(--primary));"></span>
</a>
</div>
</div>
</div>
@@ -68,6 +74,12 @@
style="border-left: 3px solid var(--secondary);">
<i class="fas fa-users mr-2"></i> Agent List
</a>
<a href="/actions-playground" class="px-3 py-2 rounded-md text-lg font-medium text-gray-400 hover:bg-gray-800 transition duration-300 relative overflow-hidden group">
<i class="fas fa-bolt mr-2"></i> Actions Playground
<!-- Underline animation -->
<span class="absolute bottom-0 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
style="background: linear-gradient(90deg, var(--tertiary), var(--primary));"></span>
</a>
</div>
</div>
</nav>