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:
parent
a5cc4db514
commit
0b9051853e
5 changed files with 487 additions and 0 deletions
31
cmd/picoclaw/internal/dashboard/command.go
Normal file
31
cmd/picoclaw/internal/dashboard/command.go
Normal 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
|
||||||
|
}
|
||||||
110
cmd/picoclaw/internal/dashboard/helpers.go
Normal file
110
cmd/picoclaw/internal/dashboard/helpers.go
Normal 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})
|
||||||
|
}
|
||||||
|
}
|
||||||
343
cmd/picoclaw/internal/dashboard/web/index.html
Normal file
343
cmd/picoclaw/internal/dashboard/web/index.html
Normal 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>
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
"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/cron"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||||
|
|
@ -37,6 +38,7 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
onboard.NewOnboardCommand(),
|
onboard.NewOnboardCommand(),
|
||||||
agent.NewAgentCommand(),
|
agent.NewAgentCommand(),
|
||||||
auth.NewAuthCommand(),
|
auth.NewAuthCommand(),
|
||||||
|
dashboard.NewDashboardCommand(),
|
||||||
gateway.NewGatewayCommand(),
|
gateway.NewGatewayCommand(),
|
||||||
status.NewStatusCommand(),
|
status.NewStatusCommand(),
|
||||||
cron.NewCronCommand(),
|
cron.NewCronCommand(),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"agent",
|
"agent",
|
||||||
"auth",
|
"auth",
|
||||||
"cron",
|
"cron",
|
||||||
|
"dashboard",
|
||||||
"gateway",
|
"gateway",
|
||||||
"migrate",
|
"migrate",
|
||||||
"onboard",
|
"onboard",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue