Compare commits

...

3 Commits

Author SHA1 Message Date
Richard Palethorpe
92272b2ec1 feat(ui): Action playground config and parameter forms
Signed-off-by: Richard Palethorpe <io@richiejp.com>
2025-04-25 15:29:05 +01:00
Ettore Di Giacinto
12209ab926 feat(browseragent): post screenshot on slack (#81)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-04-24 23:17:10 +02:00
dependabot[bot]
547e9cd0c4 chore(deps): bump actions/checkout from 2 to 4 (#44)
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-24 22:45:29 +02:00
11 changed files with 256 additions and 43 deletions

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v4
- run: | - run: |
# Add Docker's official GPG key: # Add Docker's official GPG key:
sudo apt-get update sudo apt-get update

View File

@@ -95,6 +95,11 @@ func (a *CustomAction) Run(ctx context.Context, params types.ActionParams) (type
func (a *CustomAction) Definition() types.ActionDefinition { func (a *CustomAction) Definition() types.ActionDefinition {
if a.i == nil {
xlog.Error("Interpreter is not initialized for custom action", "action", a.config["name"])
return types.ActionDefinition{}
}
v, err := a.i.Eval(fmt.Sprintf("%s.Definition", a.config["name"])) v, err := a.i.Eval(fmt.Sprintf("%s.Definition", a.config["name"]))
if err != nil { if err != nil {
xlog.Error("Error getting custom action definition", "error", err) xlog.Error("Error getting custom action definition", "error", err)

View File

@@ -1,4 +1,4 @@
package api package localoperator
import ( import (
"bytes" "bytes"

View File

@@ -10,6 +10,10 @@ import (
"github.com/sashabaranov/go-openai/jsonschema" "github.com/sashabaranov/go-openai/jsonschema"
) )
const (
MetadataBrowserAgentHistory = "browser_agent_history"
)
type BrowserAgentRunner struct { type BrowserAgentRunner struct {
baseURL, customActionName string baseURL, customActionName string
client *api.Client client *api.Client
@@ -62,7 +66,7 @@ func (b *BrowserAgentRunner) Run(ctx context.Context, params types.ActionParams)
return types.ActionResult{ return types.ActionResult{
Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr), Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr),
Metadata: map[string]interface{}{"browser_agent_history": stateHistory}, Metadata: map[string]interface{}{MetadataBrowserAgentHistory: stateHistory},
}, nil }, nil
} }

View File

@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/mudler/LocalAGI/pkg/config" "github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/mudler/LocalAGI/pkg/xlog" "github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/pkg/xstrings" "github.com/mudler/LocalAGI/pkg/xstrings"
"github.com/mudler/LocalAGI/services/actions" "github.com/mudler/LocalAGI/services/actions"
@@ -167,8 +168,38 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
return message return message
} }
func generateAttachmentsFromJobResponse(j *types.JobResult) (attachments []slack.Attachment) { func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
for _, state := range j.State { for _, state := range j.State {
// coming from the browser agent
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
state := historyStruct.States[len(historyStruct.States)-1]
// Decode base64 screenshot and upload to Slack
if state.Screenshot != "" {
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
if err != nil {
xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
continue
}
data := string(screenshotData)
// Upload the file to Slack
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(screenshotData),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: "screenshot.png",
InitialComment: "Browser Agent Screenshot",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
continue
}
}
}
}
// coming from the search action // coming from the search action
if urls, exists := state.Metadata[actions.MetadataUrls]; exists { if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
for _, url := range xstrings.UniqueSlice(urls.([]string)) { for _, url := range xstrings.UniqueSlice(urls.([]string)) {
@@ -375,7 +406,7 @@ func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackeven
slack.MsgOptionEnableLinkUnfurl(), slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(message, true), slack.MsgOptionText(message, true),
slack.MsgOptionPostMessageParameters(postMessageParams), slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...), slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
) )
if err != nil { if err != nil {
xlog.Error(fmt.Sprintf("Error posting message: %v", err)) xlog.Error(fmt.Sprintf("Error posting message: %v", err))
@@ -387,7 +418,7 @@ func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackeven
slack.MsgOptionEnableLinkUnfurl(), slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(res.Response, true), slack.MsgOptionText(res.Response, true),
slack.MsgOptionPostMessageParameters(postMessageParams), slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...), slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
// slack.MsgOptionTS(ts), // slack.MsgOptionTS(ts),
) )
if err != nil { if err != nil {
@@ -408,7 +439,7 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
slack.MsgOptionLinkNames(true), slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(), slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(messages[0], true), slack.MsgOptionText(messages[0], true),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...), slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
) )
if err != nil { if err != nil {
xlog.Error(fmt.Sprintf("Error updating final message: %v", err)) xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
@@ -435,7 +466,7 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
slack.MsgOptionLinkNames(true), slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(), slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(finalResponse, true), slack.MsgOptionText(finalResponse, true),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...), slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
) )
if err != nil { if err != nil {
xlog.Error(fmt.Sprintf("Error updating final message: %v", err)) xlog.Error(fmt.Sprintf("Error updating final message: %v", err))

View File

@@ -23,6 +23,7 @@ oauth_config:
- commands - commands
- groups:history - groups:history
- files:read - files:read
- files:write
- im:history - im:history
- im:read - im:read
- im:write - im:write

View File

@@ -419,6 +419,30 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error {
} }
} }
func (a *App) GetActionDefinition(pool *state.AgentPool) func(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error {
payload := struct {
Config map[string]string `json:"config"`
}{}
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)
a, err := services.Action(actionName, "", payload.Config, pool, map[string]string{})
if err != nil {
xlog.Error("Error creating action", "error", err)
return errorJSONMessage(c, err.Error())
}
return c.JSON(a.Definition())
}
}
func (a *App) ExecuteAction(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 { return func(c *fiber.Ctx) error {
payload := struct { payload := struct {

View File

@@ -1,6 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { actionApi } from '../utils/api'; import { actionApi, agentApi } from '../utils/api';
import FormFieldDefinition from '../components/common/FormFieldDefinition';
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import 'highlight.js/styles/monokai.css';
hljs.registerLanguage('json', json);
function ActionsPlayground() { function ActionsPlayground() {
const { showToast } = useOutletContext(); const { showToast } = useOutletContext();
@@ -12,6 +17,10 @@ function ActionsPlayground() {
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingActions, setLoadingActions] = useState(true); const [loadingActions, setLoadingActions] = useState(true);
const [actionMetadata, setActionMetadata] = useState(null);
const [agentMetadata, setAgentMetadata] = useState(null);
const [configFields, setConfigFields] = useState([]);
const [paramFields, setParamFields] = useState([]);
// Update document title // Update document title
useEffect(() => { useEffect(() => {
@@ -36,21 +45,106 @@ function ActionsPlayground() {
}; };
fetchActions(); fetchActions();
}, [showToast]); }, []);
// Fetch agent metadata on mount
useEffect(() => {
const fetchAgentMetadata = async () => {
try {
const metadata = await agentApi.getAgentConfigMetadata();
setAgentMetadata(metadata);
} catch (err) {
console.error('Error fetching agent metadata:', err);
showToast('Failed to load agent metadata', 'error');
}
};
fetchAgentMetadata();
}, []);
// Fetch action definition when action is selected or config changes
useEffect(() => {
if (!selectedAction) return;
const fetchActionDefinition = async () => {
try {
// Get config fields from agent metadata
const actionMeta = agentMetadata?.actions?.find(action => action.name === selectedAction);
const configFields = actionMeta?.fields || [];
console.debug('Config fields:', configFields);
setConfigFields(configFields);
// Parse current config to pass to action definition
let currentConfig = {};
try {
currentConfig = JSON.parse(configJson);
} catch (err) {
console.error('Error parsing current config:', err);
}
// Get parameter fields from action definition
const paramFields = await actionApi.getActionDefinition(selectedAction, currentConfig);
console.debug('Parameter fields:', paramFields);
setParamFields(paramFields);
// Reset JSON to match the new fields
setConfigJson(JSON.stringify(currentConfig, null, 2));
setParamsJson(JSON.stringify({}, null, 2));
setResult(null);
} catch (err) {
console.error('Error fetching action definition:', err);
showToast('Failed to load action definition', 'error');
}
};
fetchActionDefinition();
}, [selectedAction, agentMetadata]);
// Handle action selection // Handle action selection
const handleActionChange = (e) => { const handleActionChange = (e) => {
setSelectedAction(e.target.value); setSelectedAction(e.target.value);
setConfigJson('{}');
setParamsJson('{}');
setResult(null); setResult(null);
}; };
// Handle JSON input changes // Helper to generate onChange handlers for form fields
const handleConfigChange = (e) => { const makeFieldChangeHandler = (fields, updateFn) => (e) => {
setConfigJson(e.target.value); let value;
if (e && e.target) {
const fieldName = e.target.name;
const fieldDef = fields.find(f => f.name === fieldName);
const fieldType = fieldDef ? fieldDef.type : undefined;
if (fieldType === 'checkbox') {
value = e.target.checked;
} else if (fieldType === 'number') {
value = e.target.value === '' ? '' : Number(e.target.value);
} else {
value = e.target.value;
}
updateFn(fieldName, value);
}
}; };
const handleParamsChange = (e) => { // Handle form field changes
setParamsJson(e.target.value); const handleConfigChange = (field, value) => {
try {
const config = JSON.parse(configJson);
config[field] = value;
setConfigJson(JSON.stringify(config, null, 2));
} catch (err) {
console.error('Error updating config:', err);
}
};
const handleParamsChange = (field, value) => {
try {
const params = JSON.parse(paramsJson);
params[field] = value;
setParamsJson(JSON.stringify(params, null, 2));
} catch (err) {
console.error('Error updating params:', err);
}
}; };
// Execute the selected action // Execute the selected action
@@ -135,34 +229,31 @@ function ActionsPlayground() {
{selectedAction && ( {selectedAction && (
<div className="section-box"> <div className="section-box">
<h2>Action Configuration</h2>
<form onSubmit={handleExecuteAction}> <form onSubmit={handleExecuteAction}>
<div className="form-group mb-6"> {configFields.length > 0 && (
<label htmlFor="config-json">Configuration (JSON):</label> <>
<textarea <h2>Configuration</h2>
id="config-json" <FormFieldDefinition
value={configJson} fields={configFields}
onChange={handleConfigChange} values={JSON.parse(configJson)}
className="form-control" onChange={makeFieldChangeHandler(configFields, handleConfigChange)}
rows="5" idPrefix="config_"
placeholder='{"key": "value"}'
/> />
<p className="text-xs text-gray-400 mt-1">Enter JSON configuration for the action</p> </>
</div> )}
<div className="form-group mb-6"> {paramFields.length > 0 && (
<label htmlFor="params-json">Parameters (JSON):</label> <>
<textarea <h2>Parameters</h2>
id="params-json" <FormFieldDefinition
value={paramsJson} fields={paramFields}
onChange={handleParamsChange} values={JSON.parse(paramsJson)}
className="form-control" onChange={makeFieldChangeHandler(paramFields, handleParamsChange)}
rows="5" idPrefix="param_"
placeholder='{"key": "value"}'
/> />
<p className="text-xs text-gray-400 mt-1">Enter JSON parameters for the action</p> </>
</div> )}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -194,9 +285,9 @@ function ActionsPlayground() {
backgroundColor: 'rgba(30, 30, 30, 0.7)' backgroundColor: 'rgba(30, 30, 30, 0.7)'
}}> }}>
{typeof result === 'object' ? ( {typeof result === 'object' ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> <pre className="hljs"><code>
{JSON.stringify(result, null, 2)} <div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(result, null, 2), { language: 'json' }).value }}></div>
</pre> </code></pre>
) : ( ) : (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}> <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{result} {result}

View File

@@ -24,6 +24,50 @@ const buildUrl = (endpoint) => {
return `${API_CONFIG.baseUrl}${endpoint.startsWith('/') ? endpoint.substring(1) : endpoint}`; return `${API_CONFIG.baseUrl}${endpoint.startsWith('/') ? endpoint.substring(1) : endpoint}`;
}; };
// Helper function to convert ActionDefinition to FormFieldDefinition format
const convertActionDefinitionToFields = (definition) => {
if (!definition || !definition.Properties) {
return [];
}
const fields = [];
const required = definition.Required || [];
console.debug('Action definition:', definition);
Object.entries(definition.Properties).forEach(([name, property]) => {
const field = {
name,
label: name.charAt(0).toUpperCase() + name.slice(1),
type: 'text', // Default to text, we'll enhance this later
required: required.includes(name),
helpText: property.Description || '',
defaultValue: property.Default,
};
if (property.enum && property.enum.length > 0) {
field.type = 'select';
field.options = property.enum;
} else {
switch (property.type) {
case 'integer':
field.type = 'number';
field.min = property.Minimum;
field.max = property.Maximum;
break;
case 'boolean':
field.type = 'checkbox';
break;
}
// TODO: Handle Object and Array types which require nested fields
}
fields.push(field);
});
return fields;
};
// Agent-related API calls // Agent-related API calls
export const agentApi = { export const agentApi = {
// Get list of all agents // Get list of all agents
@@ -215,7 +259,18 @@ export const actionApi = {
}); });
return handleResponse(response); return handleResponse(response);
}, },
// Get action definition
getActionDefinition: async (name, config = {}) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.actionDefinition(name)), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify(config),
});
const definition = await handleResponse(response);
return convertActionDefinitionToFields(definition);
},
// Execute an action for an agent // Execute an action for an agent
executeAction: async (name, actionData) => { executeAction: async (name, actionData) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.executeAction(name)), { const response = await fetch(buildUrl(API_CONFIG.endpoints.executeAction(name)), {

View File

@@ -43,6 +43,7 @@ export const API_CONFIG = {
// Action endpoints // Action endpoints
listActions: '/api/actions', listActions: '/api/actions',
actionDefinition: (name) => `/api/action/${name}/definition`,
executeAction: (name) => `/api/action/${name}/run`, executeAction: (name) => `/api/action/${name}/run`,
// Status endpoint // Status endpoint

View File

@@ -188,6 +188,7 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
// Add endpoint for getting agent config metadata // Add endpoint for getting agent config metadata
webapp.Get("/api/meta/agent/config", app.GetAgentConfigMeta()) webapp.Get("/api/meta/agent/config", app.GetAgentConfigMeta())
webapp.Post("/api/action/:name/definition", app.GetActionDefinition(pool))
webapp.Post("/api/action/:name/run", app.ExecuteAction(pool)) webapp.Post("/api/action/:name/run", app.ExecuteAction(pool))
webapp.Get("/api/actions", app.ListActions()) webapp.Get("/api/actions", app.ListActions())