diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 9a3b6aa19..29de28883 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
+ "github.com/sipeed/picoclaw/pkg/dashboard"
"github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
@@ -187,6 +188,19 @@ func gatewayCmd() {
}
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
+ if cfg.Dashboard.Enabled {
+ if cfg.Dashboard.Password == "" {
+ cfg.Dashboard.Password = dashboard.GeneratePassword()
+ configPath := getConfigPath()
+ if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
+ logger.ErrorCF("dashboard", "Failed to save generated password", map[string]any{"error": saveErr.Error()})
+ }
+ fmt.Printf("🔑 Dashboard password generated: %s\n", cfg.Dashboard.Password)
+ }
+ dashConfigPath := getConfigPath()
+ dashboard.Mount(healthServer, cfg, agentLoop, channelManager, dashConfigPath)
+ fmt.Printf("✓ Dashboard available at http://%s:%d/dashboard\n", cfg.Gateway.Host, cfg.Gateway.Port)
+ }
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 20556011a..928b2d233 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -54,6 +54,7 @@ type Config struct {
Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"`
+ Dashboard DashboardConfig `json:"dashboard"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
@@ -412,6 +413,11 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
+type DashboardConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_DASHBOARD_ENABLED"`
+ Password string `json:"password" env:"PICOCLAW_DASHBOARD_PASSWORD"`
+}
+
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 7654326e7..79f819058 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -267,6 +267,10 @@ func DefaultConfig() *Config {
Host: "0.0.0.0",
Port: 18790,
},
+ Dashboard: DashboardConfig{
+ Enabled: true,
+ Password: "",
+ },
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go
new file mode 100644
index 000000000..9179f22cd
--- /dev/null
+++ b/pkg/dashboard/api.go
@@ -0,0 +1,129 @@
+package dashboard
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func statusHandler(cfg *config.Config, al *agent.AgentLoop, cm *channels.Manager, startTime time.Time) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ info := al.GetStartupInfo()
+ channelStatus := cm.GetStatus()
+
+ resp := map[string]any{
+ "uptime": time.Since(startTime).String(),
+ "running": true,
+ "tools": info["tools"],
+ "skills": info["skills"],
+ "agents": info["agents"],
+ "channels": channelStatus,
+ "model": cfg.Agents.Defaults.Model,
+ }
+ writeJSON(w, resp)
+ }
+}
+
+func configGetHandler(cfg *config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ masked := maskConfig(cfg)
+ writeJSON(w, masked)
+ }
+}
+
+func agentsHandler(cfg *config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]any{
+ "defaults": cfg.Agents.Defaults,
+ "list": cfg.Agents.List,
+ }
+ writeJSON(w, resp)
+ }
+}
+
+func modelsHandler(cfg *config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ masked := make([]map[string]any, 0, len(cfg.ModelList))
+ for _, m := range cfg.ModelList {
+ masked = append(masked, map[string]any{
+ "model_name": m.ModelName,
+ "model": m.Model,
+ "api_base": m.APIBase,
+ "api_key": maskKey(m.APIKey),
+ })
+ }
+ writeJSON(w, masked)
+ }
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(v)
+}
+
+func maskKey(key string) string {
+ if key == "" {
+ return ""
+ }
+ if len(key) <= 8 {
+ return "****"
+ }
+ return key[:3] + "..." + key[len(key)-4:]
+}
+
+func maskConfig(cfg *config.Config) map[string]any {
+ models := make([]map[string]any, 0, len(cfg.ModelList))
+ for _, m := range cfg.ModelList {
+ models = append(models, map[string]any{
+ "model_name": m.ModelName,
+ "model": m.Model,
+ "api_base": m.APIBase,
+ "api_key": maskKey(m.APIKey),
+ })
+ }
+
+ channelMap := map[string]bool{
+ "whatsapp": cfg.Channels.WhatsApp.Enabled,
+ "telegram": cfg.Channels.Telegram.Enabled,
+ "discord": cfg.Channels.Discord.Enabled,
+ "feishu": cfg.Channels.Feishu.Enabled,
+ "maixcam": cfg.Channels.MaixCam.Enabled,
+ "qq": cfg.Channels.QQ.Enabled,
+ "dingtalk": cfg.Channels.DingTalk.Enabled,
+ "slack": cfg.Channels.Slack.Enabled,
+ "line": cfg.Channels.LINE.Enabled,
+ "onebot": cfg.Channels.OneBot.Enabled,
+ "wecom": cfg.Channels.WeCom.Enabled,
+ "wecom_app": cfg.Channels.WeComApp.Enabled,
+ }
+
+ return map[string]any{
+ "agents": map[string]any{
+ "defaults": map[string]any{
+ "model": cfg.Agents.Defaults.Model,
+ "provider": cfg.Agents.Defaults.Provider,
+ "workspace": cfg.Agents.Defaults.Workspace,
+ "max_tokens": cfg.Agents.Defaults.MaxTokens,
+ },
+ "list": cfg.Agents.List,
+ },
+ "model_list": models,
+ "channels": channelMap,
+ "gateway": map[string]any{
+ "host": cfg.Gateway.Host,
+ "port": cfg.Gateway.Port,
+ },
+ }
+}
+
+func extractProvider(model string) string {
+ if idx := strings.Index(model, "/"); idx >= 0 {
+ return model[:idx]
+ }
+ return model
+}
diff --git a/pkg/dashboard/auth.go b/pkg/dashboard/auth.go
new file mode 100644
index 000000000..578c294b5
--- /dev/null
+++ b/pkg/dashboard/auth.go
@@ -0,0 +1,141 @@
+package dashboard
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const (
+ cookieName = "picoclaw_session"
+ sessionMaxAge = 24 * time.Hour
+ passwordLen = 16
+)
+
+var alphanumeric = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+
+// GeneratePassword returns a random 16-character alphanumeric password.
+func GeneratePassword() string {
+ b := make([]byte, passwordLen)
+ randomBytes := make([]byte, passwordLen)
+ if _, err := rand.Read(randomBytes); err != nil {
+ panic(fmt.Sprintf("crypto/rand failed: %v", err))
+ }
+ for i := range b {
+ b[i] = alphanumeric[int(randomBytes[i])%len(alphanumeric)]
+ }
+ return string(b)
+}
+
+func authMiddleware(password string, next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if password == "" {
+ next(w, r)
+ return
+ }
+ cookie, err := r.Cookie(cookieName)
+ if err != nil || !verifySession(cookie.Value, password) {
+ http.Redirect(w, r, "/dashboard/login", http.StatusFound)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func loginPage(password string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if password == "" {
+ http.Redirect(w, r, "/dashboard", http.StatusFound)
+ return
+ }
+ serveLogin(w, "")
+ }
+}
+
+func loginHandler(password string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/dashboard/login", http.StatusFound)
+ return
+ }
+
+ submitted := r.FormValue("password")
+ if !hmac.Equal([]byte(submitted), []byte(password)) {
+ serveLogin(w, "Invalid password")
+ return
+ }
+
+ value, expiry := signSession(password)
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: value,
+ Path: "/dashboard",
+ Expires: expiry,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ http.Redirect(w, r, "/dashboard", http.StatusFound)
+ }
+}
+
+func logoutHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: "",
+ Path: "/dashboard",
+ MaxAge: -1,
+ HttpOnly: true,
+ })
+ http.Redirect(w, r, "/dashboard/login", http.StatusFound)
+ }
+}
+
+func signSession(password string) (string, time.Time) {
+ expiry := time.Now().Add(sessionMaxAge)
+ expiryHex := fmt.Sprintf("%x", expiry.Unix())
+ mac := hmac.New(sha256.New, []byte(password))
+ mac.Write([]byte(expiryHex))
+ sig := hex.EncodeToString(mac.Sum(nil))
+ return sig + "." + expiryHex, expiry
+}
+
+func verifySession(cookie, password string) bool {
+ parts := strings.SplitN(cookie, ".", 2)
+ if len(parts) != 2 {
+ return false
+ }
+ sig, expiryHex := parts[0], parts[1]
+
+ var expiryUnix int64
+ if _, err := fmt.Sscanf(expiryHex, "%x", &expiryUnix); err != nil {
+ return false
+ }
+ if time.Now().Unix() > expiryUnix {
+ return false
+ }
+
+ mac := hmac.New(sha256.New, []byte(password))
+ mac.Write([]byte(expiryHex))
+ expected := hex.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(sig), []byte(expected))
+}
+
+func serveLogin(w http.ResponseWriter, errMsg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ loginHTML, err := staticFiles.ReadFile("static/login.html")
+ if err != nil {
+ http.Error(w, "login.html not found", http.StatusInternalServerError)
+ return
+ }
+ html := string(loginHTML)
+ if errMsg != "" {
+ html = strings.Replace(html, ``, `
`+errMsg+`
`, 1)
+ }
+ w.Write([]byte(html))
+}
diff --git a/pkg/dashboard/auth_test.go b/pkg/dashboard/auth_test.go
new file mode 100644
index 000000000..361daeca2
--- /dev/null
+++ b/pkg/dashboard/auth_test.go
@@ -0,0 +1,213 @@
+package dashboard
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestGeneratePassword(t *testing.T) {
+ pw := GeneratePassword()
+ if len(pw) != 16 {
+ t.Fatalf("expected 16 chars, got %d: %q", len(pw), pw)
+ }
+ for _, c := range pw {
+ if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
+ t.Fatalf("non-alphanumeric char in password: %c", c)
+ }
+ }
+ // Two passwords should differ
+ pw2 := GeneratePassword()
+ if pw == pw2 {
+ t.Error("two generated passwords should not be identical")
+ }
+}
+
+func TestSignVerifySession(t *testing.T) {
+ password := "testpassword123"
+ cookie, _ := signSession(password)
+ if !verifySession(cookie, password) {
+ t.Error("valid session should verify")
+ }
+}
+
+func TestVerifyExpiredSession(t *testing.T) {
+ password := "testpassword123"
+ // Sign normally then replace expiry with a past timestamp (0 = epoch)
+ cookie, _ := signSession(password)
+ parts := strings.SplitN(cookie, ".", 2)
+ pastCookie := parts[0] + ".0"
+ if verifySession(pastCookie, password) {
+ t.Error("expired session should not verify")
+ }
+}
+
+func TestVerifyTamperedSession(t *testing.T) {
+ password := "testpassword123"
+ cookie, _ := signSession(password)
+
+ // Tamper with signature
+ tampered := "deadbeef" + cookie[8:]
+ if verifySession(tampered, password) {
+ t.Error("tampered session should not verify")
+ }
+}
+
+func TestVerifyWrongPassword(t *testing.T) {
+ cookie, _ := signSession("correct")
+ if verifySession(cookie, "wrong") {
+ t.Error("session signed with different password should not verify")
+ }
+}
+
+func TestVerifyInvalidFormats(t *testing.T) {
+ tests := []string{
+ "",
+ "noseparator",
+ "abc.",
+ ".abc",
+ "abc.notahexnumber",
+ }
+ for _, cookie := range tests {
+ if verifySession(cookie, "password") {
+ t.Errorf("invalid cookie %q should not verify", cookie)
+ }
+ }
+}
+
+func TestAuthMiddlewareRedirect(t *testing.T) {
+ called := false
+ handler := authMiddleware("secret", func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if called {
+ t.Error("handler should not be called without auth cookie")
+ }
+ if w.Code != http.StatusFound {
+ t.Fatalf("expected 302, got %d", w.Code)
+ }
+ if loc := w.Header().Get("Location"); loc != "/dashboard/login" {
+ t.Fatalf("expected redirect to /dashboard/login, got %q", loc)
+ }
+}
+
+func TestAuthMiddlewareValid(t *testing.T) {
+ password := "secret"
+ called := false
+ handler := authMiddleware(password, func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ })
+
+ cookie, expiry := signSession(password)
+ req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
+ req.AddCookie(&http.Cookie{
+ Name: cookieName,
+ Value: cookie,
+ Expires: expiry,
+ })
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if !called {
+ t.Error("handler should be called with valid auth cookie")
+ }
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+}
+
+func TestNoAuthWhenNoPassword(t *testing.T) {
+ called := false
+ handler := authMiddleware("", func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if !called {
+ t.Error("handler should be called when password is empty (no auth)")
+ }
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+}
+
+func TestLoginHandler(t *testing.T) {
+ password := "mypassword"
+ handler := loginHandler(password)
+
+ form := url.Values{"password": {password}}
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusFound {
+ t.Fatalf("expected 302, got %d", w.Code)
+ }
+ if loc := w.Header().Get("Location"); loc != "/dashboard" {
+ t.Fatalf("expected redirect to /dashboard, got %q", loc)
+ }
+ cookies := w.Result().Cookies()
+ found := false
+ for _, c := range cookies {
+ if c.Name == cookieName && c.Value != "" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("login should set session cookie")
+ }
+}
+
+func TestLoginHandlerWrong(t *testing.T) {
+ password := "mypassword"
+ handler := loginHandler(password)
+
+ form := url.Values{"password": {"wrongpassword"}}
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 (re-serve login), got %d", w.Code)
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "Invalid password") {
+ t.Error("wrong password should show error message")
+ }
+}
+
+func TestLogoutHandler(t *testing.T) {
+ handler := logoutHandler()
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard/logout", nil)
+ w := httptest.NewRecorder()
+ handler(w, req)
+
+ if w.Code != http.StatusFound {
+ t.Fatalf("expected 302, got %d", w.Code)
+ }
+ if loc := w.Header().Get("Location"); loc != "/dashboard/login" {
+ t.Fatalf("expected redirect to /dashboard/login, got %q", loc)
+ }
+ cookies := w.Result().Cookies()
+ for _, c := range cookies {
+ if c.Name == cookieName && c.MaxAge != -1 {
+ t.Error("logout should set cookie MaxAge to -1")
+ }
+ }
+}
diff --git a/pkg/dashboard/crud_agents.go b/pkg/dashboard/crud_agents.go
new file mode 100644
index 000000000..1422fce67
--- /dev/null
+++ b/pkg/dashboard/crud_agents.go
@@ -0,0 +1,457 @@
+package dashboard
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/health"
+)
+
+func registerAgentsCRUD(srv *health.Server, cfg *config.Config, configPath string, auth func(http.HandlerFunc) http.HandlerFunc) {
+ srv.HandleFunc("/dashboard/fragments/agent-edit", auth(fragmentAgentEdit(cfg)))
+ srv.HandleFunc("/dashboard/fragments/agent-add", auth(fragmentAgentAdd()))
+ srv.HandleFunc("/dashboard/crud/agents/create", auth(agentCreateHandler(cfg, configPath)))
+ srv.HandleFunc("/dashboard/crud/agents/update", auth(agentUpdateHandler(cfg, configPath)))
+ srv.HandleFunc("/dashboard/crud/agents/delete", auth(agentDeleteHandler(cfg, configPath)))
+ srv.HandleFunc("/dashboard/fragments/defaults-edit", auth(fragmentDefaultsEdit(cfg)))
+ srv.HandleFunc("/dashboard/crud/agents/defaults", auth(defaultsUpdateHandler(cfg, configPath)))
+}
+
+const agentFormCSS = ``
+
+func fragmentAgentEdit(cfg *config.Config) http.HandlerFunc {
+ const tmpl = `{{.CSS}}
+Edit Agent
+`
+ t := template.Must(template.New("agent-edit").Parse(tmpl))
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ agentID := r.URL.Query().Get("id")
+ if agentID == "" {
+ http.Error(w, "missing id parameter", http.StatusBadRequest)
+ return
+ }
+
+ configMu.Lock()
+ var found *config.AgentConfig
+ for i := range cfg.Agents.List {
+ if cfg.Agents.List[i].ID == agentID {
+ found = &cfg.Agents.List[i]
+ break
+ }
+ }
+ configMu.Unlock()
+
+ if found == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprintf(w, `Agent %q not found
`, template.HTMLEscapeString(agentID))
+ return
+ }
+
+ model := ""
+ if found.Model != nil {
+ model = found.Model.Primary
+ }
+
+ ws := resolveAgentWorkspace(cfg, found)
+ instructions := readAgentInstructions(ws)
+
+ data := map[string]any{
+ "CSS": template.HTML(agentFormCSS),
+ "ID": found.ID,
+ "Name": found.Name,
+ "Model": model,
+ "Skills": strings.Join(found.Skills, ", "),
+ "Workspace": found.Workspace,
+ "Default": found.Default,
+ "Instructions": instructions,
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ t.Execute(w, data)
+ }
+}
+
+func fragmentAgentAdd() http.HandlerFunc {
+ const tmpl = `{{.CSS}}
+Add Agent
+`
+ t := template.Must(template.New("agent-add").Parse(tmpl))
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ data := map[string]any{
+ "CSS": template.HTML(agentFormCSS),
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ t.Execute(w, data)
+ }
+}
+
+func fragmentDefaultsEdit(cfg *config.Config) http.HandlerFunc {
+ const tmpl = `{{.CSS}}
+Agent Defaults
+`
+ t := template.Must(template.New("defaults-edit").Parse(tmpl))
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ configMu.Lock()
+ data := map[string]any{
+ "CSS": template.HTML(agentFormCSS),
+ "Model": cfg.Agents.Defaults.Model,
+ "MaxTokens": cfg.Agents.Defaults.MaxTokens,
+ "MaxToolIterations": cfg.Agents.Defaults.MaxToolIterations,
+ "Workspace": cfg.Agents.Defaults.Workspace,
+ "RestrictToWorkspace": cfg.Agents.Defaults.RestrictToWorkspace,
+ }
+ configMu.Unlock()
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ t.Execute(w, data)
+ }
+}
+
+func agentCreateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ id := strings.TrimSpace(r.FormValue("id"))
+ if id == "" {
+ jsonError(w, "id is required", http.StatusBadRequest)
+ return
+ }
+
+ configMu.Lock()
+ defer configMu.Unlock()
+
+ for _, a := range cfg.Agents.List {
+ if a.ID == id {
+ jsonError(w, "agent with this ID already exists", http.StatusConflict)
+ return
+ }
+ }
+
+ agent := config.AgentConfig{
+ ID: id,
+ Name: strings.TrimSpace(r.FormValue("name")),
+ Default: r.FormValue("default") == "true",
+ }
+
+ model := strings.TrimSpace(r.FormValue("model"))
+ if model != "" {
+ agent.Model = &config.AgentModelConfig{Primary: model}
+ }
+
+ agent.Skills = parseSkills(r.FormValue("skills"))
+
+ cfg.Agents.List = append(cfg.Agents.List, agent)
+
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if instructions := r.FormValue("instructions"); strings.TrimSpace(instructions) != "" {
+ ws := resolveAgentWorkspace(cfg, &agent)
+ if err := writeAgentInstructions(ws, instructions); err != nil {
+ jsonError(w, "agent created but failed to write AGENT.md: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
+ fmt.Fprint(w, `Agent created successfully
`)
+ }
+}
+
+func agentUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ id := strings.TrimSpace(r.FormValue("id"))
+ if id == "" {
+ jsonError(w, "id is required", http.StatusBadRequest)
+ return
+ }
+
+ configMu.Lock()
+ defer configMu.Unlock()
+
+ var found *config.AgentConfig
+ for i := range cfg.Agents.List {
+ if cfg.Agents.List[i].ID == id {
+ found = &cfg.Agents.List[i]
+ break
+ }
+ }
+ if found == nil {
+ jsonError(w, "agent not found", http.StatusNotFound)
+ return
+ }
+
+ found.Name = strings.TrimSpace(r.FormValue("name"))
+ found.Default = r.FormValue("default") == "true"
+ found.Workspace = strings.TrimSpace(r.FormValue("workspace"))
+ found.Skills = parseSkills(r.FormValue("skills"))
+
+ model := strings.TrimSpace(r.FormValue("model"))
+ if model != "" {
+ if found.Model == nil {
+ found.Model = &config.AgentModelConfig{}
+ }
+ found.Model.Primary = model
+ } else {
+ found.Model = nil
+ }
+
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ instructions := r.FormValue("instructions")
+ ws := resolveAgentWorkspace(cfg, found)
+ if err := writeAgentInstructions(ws, instructions); err != nil {
+ jsonError(w, "agent updated but failed to write AGENT.md: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
+ fmt.Fprint(w, `Agent updated successfully
`)
+ }
+}
+
+func agentDeleteHandler(cfg *config.Config, configPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ id := strings.TrimSpace(r.FormValue("id"))
+ if id == "" {
+ jsonError(w, "id is required", http.StatusBadRequest)
+ return
+ }
+
+ configMu.Lock()
+ defer configMu.Unlock()
+
+ idx := -1
+ for i := range cfg.Agents.List {
+ if cfg.Agents.List[i].ID == id {
+ idx = i
+ break
+ }
+ }
+ if idx == -1 {
+ jsonError(w, "agent not found", http.StatusNotFound)
+ return
+ }
+
+ cfg.Agents.List = append(cfg.Agents.List[:idx], cfg.Agents.List[idx+1:]...)
+
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
+ fmt.Fprint(w, `Agent deleted successfully
`)
+ }
+}
+
+func defaultsUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ configMu.Lock()
+ defer configMu.Unlock()
+
+ cfg.Agents.Defaults.Model = strings.TrimSpace(r.FormValue("model"))
+ cfg.Agents.Defaults.Workspace = strings.TrimSpace(r.FormValue("workspace"))
+ cfg.Agents.Defaults.RestrictToWorkspace = r.FormValue("restrict_to_workspace") == "true"
+
+ if v := strings.TrimSpace(r.FormValue("max_tokens")); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ cfg.Agents.Defaults.MaxTokens = n
+ }
+ }
+ if v := strings.TrimSpace(r.FormValue("max_tool_iterations")); v != "" {
+ if n, err := strconv.Atoi(v); err == nil {
+ cfg.Agents.Defaults.MaxToolIterations = n
+ }
+ }
+
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
+ fmt.Fprint(w, `Defaults updated successfully
`)
+ }
+}
+
+func resolveAgentWorkspace(cfg *config.Config, agent *config.AgentConfig) string {
+ if strings.TrimSpace(agent.Workspace) != "" {
+ ws := strings.TrimSpace(agent.Workspace)
+ if strings.HasPrefix(ws, "~/") {
+ home, _ := os.UserHomeDir()
+ ws = filepath.Join(home, ws[2:])
+ }
+ return ws
+ }
+ if agent.Default || agent.ID == "" || agent.ID == "main" {
+ ws := cfg.Agents.Defaults.Workspace
+ if strings.HasPrefix(ws, "~/") {
+ home, _ := os.UserHomeDir()
+ ws = filepath.Join(home, ws[2:])
+ }
+ return ws
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".picoclaw", "workspace-"+agent.ID)
+}
+
+func readAgentInstructions(workspace string) string {
+ data, err := os.ReadFile(filepath.Join(workspace, "AGENT.md"))
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
+func writeAgentInstructions(workspace, content string) error {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(workspace, "AGENT.md"), []byte(content), 0o644)
+}
+
+func parseSkills(s string) []string {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+ parts := strings.Split(s, ",")
+ var skills []string
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ skills = append(skills, p)
+ }
+ }
+ return skills
+}
diff --git a/pkg/dashboard/crud_agents_test.go b/pkg/dashboard/crud_agents_test.go
new file mode 100644
index 000000000..a0c247afe
--- /dev/null
+++ b/pkg/dashboard/crud_agents_test.go
@@ -0,0 +1,198 @@
+package dashboard
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestAgentCreateHandler(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := agentCreateHandler(cfg, configPath)
+
+ form := url.Values{
+ "id": {"test-agent"},
+ "name": {"Test Agent"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/create", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if len(cfg.Agents.List) != 1 {
+ t.Fatalf("expected 1 agent, got %d", len(cfg.Agents.List))
+ }
+ if cfg.Agents.List[0].ID != "test-agent" {
+ t.Errorf("expected id 'test-agent', got %q", cfg.Agents.List[0].ID)
+ }
+ if cfg.Agents.List[0].Name != "Test Agent" {
+ t.Errorf("expected name 'Test Agent', got %q", cfg.Agents.List[0].Name)
+ }
+
+ // Verify config file was written
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ t.Error("config file should have been created")
+ }
+}
+
+func TestAgentCreateHandlerDuplicateID(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.List = []config.AgentConfig{
+ {ID: "existing", Name: "Existing Agent"},
+ }
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := agentCreateHandler(cfg, configPath)
+
+ form := url.Values{
+ "id": {"existing"},
+ "name": {"Duplicate"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/create", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if len(cfg.Agents.List) != 1 {
+ t.Fatalf("agent list should still have 1 agent, got %d", len(cfg.Agents.List))
+ }
+}
+
+func TestAgentCreateHandlerMissingID(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := agentCreateHandler(cfg, configPath)
+
+ form := url.Values{
+ "name": {"No ID Agent"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/create", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestAgentUpdateHandler(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.List = []config.AgentConfig{
+ {ID: "agent-1", Name: "Old Name"},
+ }
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := agentUpdateHandler(cfg, configPath)
+
+ form := url.Values{
+ "id": {"agent-1"},
+ "name": {"New Name"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/update", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if cfg.Agents.List[0].Name != "New Name" {
+ t.Errorf("expected name 'New Name', got %q", cfg.Agents.List[0].Name)
+ }
+}
+
+func TestAgentDeleteHandler(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.List = []config.AgentConfig{
+ {ID: "agent-1", Name: "Agent 1"},
+ {ID: "agent-2", Name: "Agent 2"},
+ }
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := agentDeleteHandler(cfg, configPath)
+
+ form := url.Values{
+ "id": {"agent-1"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/delete", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if len(cfg.Agents.List) != 1 {
+ t.Fatalf("expected 1 agent after delete, got %d", len(cfg.Agents.List))
+ }
+ if cfg.Agents.List[0].ID != "agent-2" {
+ t.Errorf("expected remaining agent to be 'agent-2', got %q", cfg.Agents.List[0].ID)
+ }
+}
+
+func TestDefaultsUpdateHandler(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := defaultsUpdateHandler(cfg, configPath)
+
+ form := url.Values{
+ "model": {"gpt-4o"},
+ "max_tokens": {"8192"},
+ "max_tool_iterations": {"25"},
+ "workspace": {"~/workspace"},
+ }
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/agents/defaults", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if cfg.Agents.Defaults.Model != "gpt-4o" {
+ t.Errorf("expected model 'gpt-4o', got %q", cfg.Agents.Defaults.Model)
+ }
+ if cfg.Agents.Defaults.MaxTokens != 8192 {
+ t.Errorf("expected max_tokens 8192, got %d", cfg.Agents.Defaults.MaxTokens)
+ }
+ if cfg.Agents.Defaults.MaxToolIterations != 25 {
+ t.Errorf("expected max_tool_iterations 25, got %d", cfg.Agents.Defaults.MaxToolIterations)
+ }
+ if cfg.Agents.Defaults.Workspace != "~/workspace" {
+ t.Errorf("expected workspace '~/workspace', got %q", cfg.Agents.Defaults.Workspace)
+ }
+}
diff --git a/pkg/dashboard/crud_channels.go b/pkg/dashboard/crud_channels.go
new file mode 100644
index 000000000..e4b7c2d50
--- /dev/null
+++ b/pkg/dashboard/crud_channels.go
@@ -0,0 +1,342 @@
+package dashboard
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/health"
+)
+
+func registerChannelsCRUD(srv *health.Server, cfg *config.Config, configPath string, auth func(http.HandlerFunc) http.HandlerFunc) {
+ srv.HandleFunc("/dashboard/fragments/channel-edit", auth(fragmentChannelEdit(cfg)))
+ srv.HandleFunc("/dashboard/crud/channels/update", auth(channelUpdateHandler(cfg, configPath)))
+ srv.HandleFunc("/dashboard/crud/channels/toggle", auth(channelToggleHandler(cfg, configPath)))
+}
+
+const channelFormCSS = ``
+
+func channelEditFormHTML(name string, cfg *config.Config) string {
+ var enabled bool
+ var allowFrom []string
+ var fields string
+
+ switch name {
+ case "telegram":
+ ch := cfg.Channels.Telegram
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("token", "Token", ch.Token) +
+ textField("proxy", "Proxy", ch.Proxy)
+ case "discord":
+ ch := cfg.Channels.Discord
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("token", "Token", ch.Token) +
+ checkboxField("mention_only", "Mention Only", ch.MentionOnly)
+ case "slack":
+ ch := cfg.Channels.Slack
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("bot_token", "Bot Token", ch.BotToken) +
+ textField("app_token", "App Token", ch.AppToken)
+ case "whatsapp":
+ ch := cfg.Channels.WhatsApp
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("bridge_url", "Bridge URL", ch.BridgeURL)
+ case "feishu":
+ ch := cfg.Channels.Feishu
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("app_id", "App ID", ch.AppID) +
+ textField("app_secret", "App Secret", ch.AppSecret) +
+ textField("encrypt_key", "Encrypt Key", ch.EncryptKey) +
+ textField("verification_token", "Verification Token", ch.VerificationToken)
+ case "dingtalk":
+ ch := cfg.Channels.DingTalk
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("client_id", "Client ID", ch.ClientID) +
+ textField("client_secret", "Client Secret", ch.ClientSecret)
+ case "qq":
+ ch := cfg.Channels.QQ
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("app_id", "App ID", ch.AppID) +
+ textField("app_secret", "App Secret", ch.AppSecret)
+ case "line":
+ ch := cfg.Channels.LINE
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("channel_secret", "Channel Secret", ch.ChannelSecret) +
+ textField("channel_access_token", "Channel Access Token", ch.ChannelAccessToken) +
+ textField("webhook_host", "Webhook Host", ch.WebhookHost) +
+ textField("webhook_port", "Webhook Port", strconv.Itoa(ch.WebhookPort)) +
+ textField("webhook_path", "Webhook Path", ch.WebhookPath)
+ case "maixcam":
+ ch := cfg.Channels.MaixCam
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("host", "Host", ch.Host) +
+ textField("port", "Port", strconv.Itoa(ch.Port))
+ case "onebot":
+ ch := cfg.Channels.OneBot
+ enabled = ch.Enabled
+ allowFrom = ch.AllowFrom
+ fields = textField("ws_url", "WebSocket URL", ch.WSUrl) +
+ textField("access_token", "Access Token", ch.AccessToken) +
+ textField("reconnect_interval", "Reconnect Interval", strconv.Itoa(ch.ReconnectInterval))
+ default:
+ return ""
+ }
+
+ checkedAttr := ""
+ if enabled {
+ checkedAttr = " checked"
+ }
+
+ allowFromStr := strings.Join(allowFrom, ", ")
+
+ return channelFormCSS + `
+
Edit Channel: ` + template.HTMLEscapeString(name) + `
+
+
`
+}
+
+func textField(name, label, value string) string {
+ return `
+
+
+
+
`
+}
+
+func checkboxField(name, label string, checked bool) string {
+ checkedAttr := ""
+ if checked {
+ checkedAttr = " checked"
+ }
+ return `
+
+
+
+
`
+}
+
+func fragmentChannelEdit(cfg *config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ name := r.URL.Query().Get("name")
+ html := channelEditFormHTML(name, cfg)
+ if html == "" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ fmt.Fprintf(w, `Unknown channel: %s
`, template.HTMLEscapeString(name))
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(html))
+ }
+}
+
+func parseAllowFrom(s string) config.FlexibleStringSlice {
+ if strings.TrimSpace(s) == "" {
+ return nil
+ }
+ parts := strings.Split(s, ",")
+ result := make(config.FlexibleStringSlice, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ result = append(result, p)
+ }
+ }
+ return result
+}
+
+func channelUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ fmt.Fprint(w, `Method not allowed
`)
+ return
+ }
+
+ name := r.FormValue("name")
+ enabled := r.FormValue("enabled") == "true"
+ allowFrom := parseAllowFrom(r.FormValue("allow_from"))
+
+ configMu.Lock()
+ switch name {
+ case "telegram":
+ cfg.Channels.Telegram.Enabled = enabled
+ cfg.Channels.Telegram.Token = r.FormValue("token")
+ cfg.Channels.Telegram.Proxy = r.FormValue("proxy")
+ cfg.Channels.Telegram.AllowFrom = allowFrom
+ case "discord":
+ cfg.Channels.Discord.Enabled = enabled
+ cfg.Channels.Discord.Token = r.FormValue("token")
+ cfg.Channels.Discord.MentionOnly = r.FormValue("mention_only") == "true"
+ cfg.Channels.Discord.AllowFrom = allowFrom
+ case "slack":
+ cfg.Channels.Slack.Enabled = enabled
+ cfg.Channels.Slack.BotToken = r.FormValue("bot_token")
+ cfg.Channels.Slack.AppToken = r.FormValue("app_token")
+ cfg.Channels.Slack.AllowFrom = allowFrom
+ case "whatsapp":
+ cfg.Channels.WhatsApp.Enabled = enabled
+ cfg.Channels.WhatsApp.BridgeURL = r.FormValue("bridge_url")
+ cfg.Channels.WhatsApp.AllowFrom = allowFrom
+ case "feishu":
+ cfg.Channels.Feishu.Enabled = enabled
+ cfg.Channels.Feishu.AppID = r.FormValue("app_id")
+ cfg.Channels.Feishu.AppSecret = r.FormValue("app_secret")
+ cfg.Channels.Feishu.EncryptKey = r.FormValue("encrypt_key")
+ cfg.Channels.Feishu.VerificationToken = r.FormValue("verification_token")
+ cfg.Channels.Feishu.AllowFrom = allowFrom
+ case "dingtalk":
+ cfg.Channels.DingTalk.Enabled = enabled
+ cfg.Channels.DingTalk.ClientID = r.FormValue("client_id")
+ cfg.Channels.DingTalk.ClientSecret = r.FormValue("client_secret")
+ cfg.Channels.DingTalk.AllowFrom = allowFrom
+ case "qq":
+ cfg.Channels.QQ.Enabled = enabled
+ cfg.Channels.QQ.AppID = r.FormValue("app_id")
+ cfg.Channels.QQ.AppSecret = r.FormValue("app_secret")
+ cfg.Channels.QQ.AllowFrom = allowFrom
+ case "line":
+ cfg.Channels.LINE.Enabled = enabled
+ cfg.Channels.LINE.ChannelSecret = r.FormValue("channel_secret")
+ cfg.Channels.LINE.ChannelAccessToken = r.FormValue("channel_access_token")
+ cfg.Channels.LINE.WebhookHost = r.FormValue("webhook_host")
+ if p, err := strconv.Atoi(r.FormValue("webhook_port")); err == nil {
+ cfg.Channels.LINE.WebhookPort = p
+ }
+ cfg.Channels.LINE.WebhookPath = r.FormValue("webhook_path")
+ cfg.Channels.LINE.AllowFrom = allowFrom
+ case "maixcam":
+ cfg.Channels.MaixCam.Enabled = enabled
+ cfg.Channels.MaixCam.Host = r.FormValue("host")
+ if p, err := strconv.Atoi(r.FormValue("port")); err == nil {
+ cfg.Channels.MaixCam.Port = p
+ }
+ cfg.Channels.MaixCam.AllowFrom = allowFrom
+ case "onebot":
+ cfg.Channels.OneBot.Enabled = enabled
+ cfg.Channels.OneBot.WSUrl = r.FormValue("ws_url")
+ cfg.Channels.OneBot.AccessToken = r.FormValue("access_token")
+ if ri, err := strconv.Atoi(r.FormValue("reconnect_interval")); err == nil {
+ cfg.Channels.OneBot.ReconnectInterval = ri
+ }
+ cfg.Channels.OneBot.AllowFrom = allowFrom
+ default:
+ configMu.Unlock()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ fmt.Fprintf(w, `Unknown channel: %s
`, template.HTMLEscapeString(name))
+ return
+ }
+ configMu.Unlock()
+
+ if configPath != "" {
+ if err := saveConfig(configPath, cfg); err != nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusInternalServerError)
+ fmt.Fprintf(w, `Failed to save: %s
`, template.HTMLEscapeString(err.Error()))
+ return
+ }
+ }
+
+ w.Header().Set("HX-Trigger", "refreshChannels, closeModal")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, `Channel updated
`)
+ }
+}
+
+func channelToggleHandler(cfg *config.Config, configPath string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ fmt.Fprint(w, `Method not allowed
`)
+ return
+ }
+
+ name := r.FormValue("name")
+ enabled := r.FormValue("enabled") == "true"
+
+ configMu.Lock()
+ switch name {
+ case "telegram":
+ cfg.Channels.Telegram.Enabled = enabled
+ case "discord":
+ cfg.Channels.Discord.Enabled = enabled
+ case "slack":
+ cfg.Channels.Slack.Enabled = enabled
+ case "whatsapp":
+ cfg.Channels.WhatsApp.Enabled = enabled
+ case "feishu":
+ cfg.Channels.Feishu.Enabled = enabled
+ case "dingtalk":
+ cfg.Channels.DingTalk.Enabled = enabled
+ case "qq":
+ cfg.Channels.QQ.Enabled = enabled
+ case "line":
+ cfg.Channels.LINE.Enabled = enabled
+ case "maixcam":
+ cfg.Channels.MaixCam.Enabled = enabled
+ case "onebot":
+ cfg.Channels.OneBot.Enabled = enabled
+ default:
+ configMu.Unlock()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ fmt.Fprintf(w, `Unknown channel: %s
`, template.HTMLEscapeString(name))
+ return
+ }
+ configMu.Unlock()
+
+ if configPath != "" {
+ if err := saveConfig(configPath, cfg); err != nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusInternalServerError)
+ fmt.Fprintf(w, `Failed to save: %s
`, template.HTMLEscapeString(err.Error()))
+ return
+ }
+ }
+
+ w.Header().Set("HX-Trigger", "refreshChannels")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, `Channel toggled
`)
+ }
+}
diff --git a/pkg/dashboard/crud_channels_test.go b/pkg/dashboard/crud_channels_test.go
new file mode 100644
index 000000000..035de967e
--- /dev/null
+++ b/pkg/dashboard/crud_channels_test.go
@@ -0,0 +1,215 @@
+package dashboard
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestChannelToggle(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Channels.Telegram.Enabled = false
+
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := channelToggleHandler(cfg, configPath)
+
+ form := url.Values{}
+ form.Set("name", "telegram")
+ form.Set("enabled", "true")
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/toggle", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if !cfg.Channels.Telegram.Enabled {
+ t.Error("expected Telegram to be enabled after toggle")
+ }
+
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ t.Error("config file should have been saved")
+ }
+}
+
+func TestChannelUpdateTelegram(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := channelUpdateHandler(cfg, configPath)
+
+ form := url.Values{}
+ form.Set("name", "telegram")
+ form.Set("enabled", "true")
+ form.Set("token", "bot123456:ABC-DEF")
+ form.Set("proxy", "socks5://proxy:1080")
+ form.Set("allow_from", "user1, user2, user3")
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/update", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if !cfg.Channels.Telegram.Enabled {
+ t.Error("expected Telegram enabled")
+ }
+ if cfg.Channels.Telegram.Token != "bot123456:ABC-DEF" {
+ t.Errorf("expected token 'bot123456:ABC-DEF', got %q", cfg.Channels.Telegram.Token)
+ }
+ if cfg.Channels.Telegram.Proxy != "socks5://proxy:1080" {
+ t.Errorf("expected proxy 'socks5://proxy:1080', got %q", cfg.Channels.Telegram.Proxy)
+ }
+ if len(cfg.Channels.Telegram.AllowFrom) != 3 {
+ t.Fatalf("expected 3 allow_from entries, got %d", len(cfg.Channels.Telegram.AllowFrom))
+ }
+ if cfg.Channels.Telegram.AllowFrom[0] != "user1" {
+ t.Errorf("expected allow_from[0] = 'user1', got %q", cfg.Channels.Telegram.AllowFrom[0])
+ }
+}
+
+func TestChannelUpdateDiscord(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ handler := channelUpdateHandler(cfg, configPath)
+
+ form := url.Values{}
+ form.Set("name", "discord")
+ form.Set("enabled", "true")
+ form.Set("token", "discord-bot-token-xyz")
+ form.Set("mention_only", "true")
+ form.Set("allow_from", "guild1")
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/update", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if cfg.Channels.Discord.Token != "discord-bot-token-xyz" {
+ t.Errorf("expected discord token, got %q", cfg.Channels.Discord.Token)
+ }
+ if !cfg.Channels.Discord.MentionOnly {
+ t.Error("expected MentionOnly to be true")
+ }
+}
+
+func TestChannelUpdateUnknown(t *testing.T) {
+ cfg := config.DefaultConfig()
+
+ handler := channelUpdateHandler(cfg, "")
+
+ form := url.Values{}
+ form.Set("name", "nonexistent")
+ req := httptest.NewRequest(http.MethodPost, "/dashboard/crud/channels/update", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for unknown channel, got %d", w.Code)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "Unknown channel") {
+ t.Error("expected 'Unknown channel' error message")
+ }
+}
+
+func TestChannelEditFragment(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Channels.Telegram.Token = "test-token-123"
+ cfg.Channels.Telegram.Enabled = true
+
+ handler := fragmentChannelEdit(cfg)
+
+ req := httptest.NewRequest(http.MethodGet, "/dashboard/fragments/channel-edit?name=telegram", nil)
+ w := httptest.NewRecorder()
+
+ handler(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "