feat: add web-based configuration dashboard

Implemented a new `dashboard` command that starts a web server for easy,
user-friendly configuration of the AI agent.

- Backend: Go HTTP server with API endpoints for reading/writing config.
- Frontend: Modern, responsive dashboard using Tailwind CSS and Alpine.js.
- CLI: Integrated `picoclaw dashboard` command with host/port options.
- UI: Forms for general settings, model management, channels, tools, and heartbeat.
- Advanced: Raw JSON editor for full control.

Addresses the request for a seamless web-based configuration experience.

Co-authored-by: Xeven777 <115650165+Xeven777@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-02-26 08:09:10 +00:00
parent a5cc4db514
commit 0b9051853e
5 changed files with 487 additions and 0 deletions

View file

@ -0,0 +1,31 @@
package dashboard
import (
"embed"
"github.com/spf13/cobra"
)
//go:embed web/index.html
var embeddedFiles embed.FS
func NewDashboardCommand() *cobra.Command {
var host string
var port int
var noBrowser bool
cmd := &cobra.Command{
Use: "dashboard",
Aliases: []string{"d", "ui"},
Short: "Start the web-based configuration dashboard",
RunE: func(cmd *cobra.Command, args []string) error {
return runDashboard(host, port, !noBrowser)
},
}
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host to bind to")
cmd.Flags().IntVarP(&port, "port", "p", 18795, "Port to listen on")
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not open the browser automatically")
return cmd
}

View file

@ -0,0 +1,110 @@
package dashboard
import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"runtime"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
func runDashboard(host string, port int, openBrowser bool) error {
addr := fmt.Sprintf("%s:%d", host, port)
url := fmt.Sprintf("http://%s", addr)
if host == "0.0.0.0" {
url = fmt.Sprintf("http://localhost:%d", port)
}
mux := http.NewServeMux()
// API Handlers
mux.HandleFunc("/api/config", configHandler)
// Static Assets
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
data, err := embeddedFiles.ReadFile("web/index.html")
if err != nil {
http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
})
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
}
fmt.Printf("🚀 PicoClaw Dashboard starting on %s\n", url)
if openBrowser {
go func() {
time.Sleep(500 * time.Millisecond)
openInBrowser(url)
}()
}
return server.ListenAndServe()
}
func configHandler(w http.ResponseWriter, r *http.Request) {
configPath := internal.GetConfigPath()
switch r.Method {
case http.MethodGet:
cfg, err := internal.LoadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
case http.MethodPost:
var cfg config.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := config.SaveConfig(configPath, &cfg); err != nil {
http.Error(w, "Failed to save config: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func openInBrowser(url string) {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
if err != nil {
logger.WarnCF("dashboard", "Failed to open browser", map[string]any{"error": err.Error(), "url": url})
}
}

View file

@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PicoClaw Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="bg-gray-50 text-gray-900 font-sans">
<div x-data="dashboard" class="min-h-screen flex flex-col" x-cloak>
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="text-3xl">🦞</span>
<h1 class="text-xl font-bold tracking-tight">PicoClaw Dashboard</h1>
</div>
<div class="flex items-center space-x-4">
<button @click="saveConfig" :disabled="saving" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50 flex items-center">
<template x-if="saving">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
</template>
<span x-text="saving ? 'Saving...' : 'Save Configuration'"></span>
</button>
</div>
</header>
<div class="flex-1 flex overflow-hidden">
<!-- Sidebar -->
<nav class="w-64 bg-white border-r border-gray-200 flex-shrink-0 overflow-y-auto">
<div class="p-4 space-y-1">
<template x-for="item in menuItems" :key="item.id">
<button @click="activeTab = item.id"
:class="activeTab === item.id ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
class="w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors">
<span x-text="item.label"></span>
</button>
</template>
</div>
</nav>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto p-8">
<div class="max-w-4xl mx-auto">
<!-- Notifications -->
<div x-show="notification"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
:class="notification?.type === 'success' ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800'"
class="mb-6 p-4 rounded-md border flex items-center justify-between">
<div class="flex items-center">
<span x-text="notification?.message"></span>
</div>
<button @click="notification = null" class="text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<!-- General Settings -->
<div x-show="activeTab === 'general'" class="space-y-6">
<h2 class="text-2xl font-semibold">General Settings</h2>
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Workspace Path</label>
<input type="text" x-model="config.agents.defaults.workspace" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<p class="mt-1 text-xs text-gray-500">Default: ~/.picoclaw/workspace</p>
</div>
<div class="flex items-center">
<input type="checkbox" x-model="config.agents.defaults.restrict_to_workspace" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<label class="ml-2 block text-sm text-gray-900 font-medium">Restrict to Workspace</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Model</label>
<select x-model="config.agents.defaults.model_name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<template x-for="model in (config.model_list || [])" :key="model.model_name">
<option :value="model.model_name" x-text="model.model_name"></option>
</template>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Max Tokens</label>
<input type="number" x-model.number="config.agents.defaults.max_tokens" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Temperature</label>
<input type="number" step="0.1" x-model.number="config.agents.defaults.temperature" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
</div>
</div>
</div>
<!-- Models -->
<div x-show="activeTab === 'models'" class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Model List</h2>
<button @click="addModel" class="bg-indigo-50 text-indigo-700 hover:bg-indigo-100 px-3 py-1.5 rounded-md text-sm font-medium transition-colors">
+ Add Model
</button>
</div>
<div class="space-y-4">
<template x-for="(model, index) in config.model_list" :key="index">
<div class="bg-white shadow rounded-lg p-6 relative">
<button @click="removeModel(index)" class="absolute top-4 right-4 text-gray-400 hover:text-red-500 transition-colors">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</button>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Model Name (Alias)</label>
<input type="text" x-model="model.model_name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="gpt4">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Model Identifier</label>
<input type="text" x-model="model.model" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="openai/gpt-4">
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700">API Key</label>
<input type="password" x-model="model.api_key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="sk-...">
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700">API Base (Optional)</label>
<input type="text" x-model="model.api_base" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="https://api.openai.com/v1">
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Channels -->
<div x-show="activeTab === 'channels'" class="space-y-6">
<h2 class="text-2xl font-semibold">Channels</h2>
<div class="space-y-4">
<!-- Telegram -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Telegram</h3>
<input type="checkbox" x-model="config.channels.telegram.enabled" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<div x-show="config.channels?.telegram?.enabled" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Bot Token</label>
<input type="password" x-model="config.channels.telegram.token" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Allowed User IDs (comma separated)</label>
<input type="text" :value="(config.channels?.telegram?.allow_from || []).join(', ')" @input="config.channels.telegram.allow_from = $event.target.value.split(',').map(s => s.trim())" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 sm:text-sm">
</div>
</div>
</div>
<!-- Discord -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Discord</h3>
<input type="checkbox" x-model="config.channels.discord.enabled" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<div x-show="config.channels?.discord?.enabled" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Bot Token</label>
<input type="password" x-model="config.channels.discord.token" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 sm:text-sm">
</div>
<div class="flex items-center">
<input type="checkbox" x-model="config.channels.discord.mention_only" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
<label class="ml-2 block text-sm text-gray-900">Mention Only</label>
</div>
</div>
</div>
<!-- More channels can be added here -->
<p class="text-sm text-gray-500 italic">More channels available in the raw config editor.</p>
</div>
</div>
<!-- Tools -->
<div x-show="activeTab === 'tools'" class="space-y-6">
<h2 class="text-2xl font-semibold">Tools</h2>
<!-- Web Search -->
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<h3 class="text-lg font-medium border-b pb-2">Web Search</h3>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">Brave</span>
<input type="checkbox" x-model="config.tools.web.brave.enabled" class="h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<input type="password" x-model="config.tools.web.brave.api_key" placeholder="API Key" class="w-full border border-gray-300 rounded-md shadow-sm p-2 sm:text-sm" x-show="config.tools.web.brave.enabled">
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">DuckDuckGo</span>
<input type="checkbox" x-model="config.tools.web.duckduckgo.enabled" class="h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
</div>
</div>
</div>
<!-- Exec -->
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<h3 class="text-lg font-medium border-b pb-2">Execution Safety</h3>
<div class="flex items-center">
<input type="checkbox" x-model="config.tools.exec.enable_deny_patterns" class="h-4 w-4 text-indigo-600 border-gray-300 rounded">
<label class="ml-2 block text-sm text-gray-900">Enable Deny Patterns</label>
</div>
</div>
</div>
<!-- Heartbeat -->
<div x-show="activeTab === 'heartbeat'" class="space-y-6">
<h2 class="text-2xl font-semibold">Heartbeat</h2>
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700">Enabled</label>
<input type="checkbox" x-model="config.heartbeat.enabled" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Interval (minutes)</label>
<input type="number" x-model.number="config.heartbeat.interval" min="5" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 sm:text-sm">
</div>
</div>
</div>
<!-- Advanced -->
<div x-show="activeTab === 'advanced'" class="space-y-6">
<h2 class="text-2xl font-semibold">Advanced (Raw JSON)</h2>
<div class="bg-white shadow rounded-lg p-6">
<textarea x-model="rawConfig" class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500" spellcheck="false"></textarea>
<div class="mt-4 flex justify-end">
<button @click="applyRawConfig" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium">
Apply Raw JSON to Forms
</button>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dashboard', () => ({
activeTab: 'general',
config: null,
rawConfig: '',
saving: false,
notification: null,
menuItems: [
{ id: 'general', label: 'General' },
{ id: 'models', label: 'Models' },
{ id: 'channels', label: 'Channels' },
{ id: 'tools', label: 'Tools' },
{ id: 'heartbeat', label: 'Heartbeat' },
{ id: 'advanced', label: 'Advanced' },
],
async init() {
await this.fetchConfig();
},
async fetchConfig() {
try {
const response = await fetch('/api/config');
this.config = await response.json();
this.rawConfig = JSON.stringify(this.config, null, 2);
} catch (err) {
this.showNotification('error', 'Failed to load configuration');
}
},
async saveConfig() {
this.saving = true;
try {
// Sync raw config from form before saving
this.rawConfig = JSON.stringify(this.config, null, 2);
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: this.rawConfig
});
if (response.ok) {
this.showNotification('success', 'Configuration saved successfully');
} else {
const error = await response.text();
this.showNotification('error', 'Failed to save: ' + error);
}
} catch (err) {
this.showNotification('error', 'Network error while saving');
} finally {
this.saving = false;
}
},
applyRawConfig() {
try {
this.config = JSON.parse(this.rawConfig);
this.showNotification('success', 'JSON applied to forms');
} catch (err) {
this.showNotification('error', 'Invalid JSON: ' + err.message);
}
},
showNotification(type, message) {
this.notification = { type, message };
setTimeout(() => {
if (this.notification?.message === message) {
this.notification = null;
}
}, 5000);
},
addModel() {
if (!this.config.model_list) this.config.model_list = [];
this.config.model_list.push({
model_name: '',
model: '',
api_key: '',
api_base: ''
});
},
removeModel(index) {
this.config.model_list.splice(index, 1);
}
}));
});
</script>
</body>
</html>

View file

@ -15,6 +15,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/dashboard"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
@ -37,6 +38,7 @@ func NewPicoclawCommand() *cobra.Command {
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
auth.NewAuthCommand(),
dashboard.NewDashboardCommand(),
gateway.NewGatewayCommand(),
status.NewStatusCommand(),
cron.NewCronCommand(),

View file

@ -36,6 +36,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"agent",
"auth",
"cron",
"dashboard",
"gateway",
"migrate",
"onboard",