diff --git a/cmd/picoclaw/internal/dashboard/command.go b/cmd/picoclaw/internal/dashboard/command.go new file mode 100644 index 000000000..880ba8dd0 --- /dev/null +++ b/cmd/picoclaw/internal/dashboard/command.go @@ -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 +} diff --git a/cmd/picoclaw/internal/dashboard/helpers.go b/cmd/picoclaw/internal/dashboard/helpers.go new file mode 100644 index 000000000..6ac1d4b50 --- /dev/null +++ b/cmd/picoclaw/internal/dashboard/helpers.go @@ -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}) + } +} diff --git a/cmd/picoclaw/internal/dashboard/web/index.html b/cmd/picoclaw/internal/dashboard/web/index.html new file mode 100644 index 000000000..e1d013845 --- /dev/null +++ b/cmd/picoclaw/internal/dashboard/web/index.html @@ -0,0 +1,343 @@ + + + + + + PicoClaw Dashboard + + + + + +
+ +
+
+ 🦞 +

PicoClaw Dashboard

+
+
+ +
+
+ +
+ + + + +
+
+ +
+
+ +
+ +
+ + +
+

General Settings

+ +
+
+ + +

Default: ~/.picoclaw/workspace

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

Model List

+ +
+ +
+ +
+
+ + +
+

Channels

+ +
+ +
+
+

Telegram

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+

Discord

+ +
+
+
+ + +
+
+ + +
+
+
+ + +

More channels available in the raw config editor.

+
+
+ + +
+

Tools

+ + +
+

Web Search

+ +
+
+
+ Brave + +
+ +
+ +
+
+ DuckDuckGo + +
+
+
+
+ + +
+

Execution Safety

+
+ + +
+
+
+ + +
+

Heartbeat

+ +
+
+ + +
+
+ + +
+
+
+ + +
+

Advanced (Raw JSON)

+
+ +
+ +
+
+
+ +
+
+
+
+ + + + diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 6db69c990..acad12ee4 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -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(), diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3740ba358..7f44445d0 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -36,6 +36,7 @@ func TestNewPicoclawCommand(t *testing.T) { "agent", "auth", "cron", + "dashboard", "gateway", "migrate", "onboard",