From 554ad4e9328f89a47095e865fc3b722104ab11db Mon Sep 17 00:00:00 2001 From: Guglielmo Caponi <153330034+gcaponi@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:10:51 +0100 Subject: [PATCH 1/2] feat: add web-based Gateway Dashboard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a built-in web dashboard to the PicoClaw gateway, accessible at `http://:/dashboard` with no additional dependencies or configuration required. The dashboard is served by the existing gateway HTTP server and embedded directly into the binary. ## Changes ### New: `pkg/dashboard/` - `server.go` — REST API handlers for all dashboard endpoints - `ui.go` — Single-file SPA frontend embedded as a Go string constant ### Modified: `pkg/channels/manager.go` - Added `Mux() *http.ServeMux` method to expose the shared HTTP mux to external packages ### Modified: `cmd/picoclaw/internal/gateway/helpers.go` - Initialize and register the dashboard server on gateway startup ## Features | Section | Description | |---------|-------------| | Overview | Live stats: agents, skills, cron jobs, sessions | | Config Editor | Read/write `config.json` directly from the browser | | Agents | View configured agents, model, workspace, linked skills | | SOUL.md Editor | Edit agent personality directly from the browser | | Skills | Browse, create, edit, and delete skills with inline SKILL.md editor | | Cron Jobs | Create, enable/disable, and delete scheduled jobs | | Sessions | View active session files with size and timestamp | | Logs | Tail last 200 lines of the gateway log | ## API Endpoints ``` GET/PUT /api/config GET /api/agents GET/PUT /api/soul GET/PUT/POST/DELETE /api/skill?name=X GET /api/skills GET/POST/PUT/DELETE /api/cron GET /api/sessions GET /api/logs GET /api/status ``` ## Notes - No authentication (designed for local network / self-hosted use) - No new dependencies — uses only Go standard library - Frontend is a single-file SPA with no build step required - Dashboard auto-refreshes uptime every 10 seconds - Config and SOUL.md edits take effect on next agent interaction --- pkg/dashboard/server.go | 409 +++++++++++++++++++++++ pkg/dashboard/ui.go | 709 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1118 insertions(+) create mode 100644 pkg/dashboard/server.go create mode 100644 pkg/dashboard/ui.go diff --git a/pkg/dashboard/server.go b/pkg/dashboard/server.go new file mode 100644 index 000000000..b2dd41f0a --- /dev/null +++ b/pkg/dashboard/server.go @@ -0,0 +1,409 @@ +// Package dashboard provides a web-based management UI for PicoClaw. +package dashboard + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type Server struct { + cfg *config.Config + cronService *cron.CronService + startTime time.Time + workDir string + configPath string +} + +func NewServer(cfg *config.Config, cronService *cron.CronService, configPath string) *Server { + return &Server{ + cfg: cfg, + cronService: cronService, + startTime: time.Now(), + workDir: cfg.WorkspacePath(), + configPath: configPath, + } +} + +func (s *Server) RegisterOnMux(mux *http.ServeMux) { + mux.HandleFunc("/dashboard", s.handleUI) + mux.HandleFunc("/dashboard/", s.handleUI) + mux.HandleFunc("/api/status", s.withCORS(s.handleStatus)) + mux.HandleFunc("/api/config", s.withCORS(s.handleConfig)) + mux.HandleFunc("/api/agents", s.withCORS(s.handleAgents)) + mux.HandleFunc("/api/skills", s.withCORS(s.handleSkills)) + mux.HandleFunc("/api/cron", s.withCORS(s.handleCron)) + mux.HandleFunc("/api/sessions", s.withCORS(s.handleSessions)) + mux.HandleFunc("/api/logs", s.withCORS(s.handleLogs)) + mux.HandleFunc("/api/soul", s.withCORS(s.handleSoul)) + mux.HandleFunc("/api/skill", s.withCORS(s.handleSkillFile)) + logger.InfoCF("dashboard", "Dashboard registered", map[string]any{"path": "/dashboard"}) +} + +func (s *Server) withCORS(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + h(w, r) + } +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(dashboardHTML)) +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "uptime": time.Since(s.startTime).String(), + "workspace": s.workDir, + }) +} + +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + data, err := os.ReadFile(s.configPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "cannot read config: "+err.Error()) + return + } + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + writeError(w, http.StatusInternalServerError, "invalid config JSON: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, raw) + case http.MethodPut: + var raw any + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + writeError(w, http.StatusInternalServerError, "marshal error: "+err.Error()) + return + } + if err := os.WriteFile(s.configPath, data, 0o600); err != nil { + writeError(w, http.StatusInternalServerError, "cannot write config: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "saved"}) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +type agentInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Default bool `json:"default"` + Skills []string `json:"skills"` + HasSoul bool `json:"has_soul"` + ModelName string `json:"model_name"` + Workspace string `json:"workspace"` +} + +func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { + result := make([]agentInfo, 0) + soulPath := filepath.Join(s.workDir, "SOUL.md") + _, hasSoul := os.Stat(soulPath) + + // PicoClaw uses a single default agent — expose it as the main agent + defaults := s.cfg.Agents.Defaults + skillsDir := filepath.Join(s.workDir, "skills") + skills := []string{} + if entries, err := os.ReadDir(skillsDir); err == nil { + for _, e := range entries { + if e.IsDir() { + skills = append(skills, e.Name()) + } + } + } + result = append(result, agentInfo{ + ID: "main", + Name: "Default Agent", + Default: true, + Skills: skills, + HasSoul: hasSoul == nil, + ModelName: defaults.ModelName, + Workspace: defaults.Workspace, + }) + + // Also add any named agents from the list if present + for _, a := range s.cfg.Agents.List { + result = append(result, agentInfo{ + ID: a.ID, + Name: a.Name, + Default: a.Default, + Skills: a.Skills, + HasSoul: hasSoul == nil, + }) + } + + writeJSON(w, http.StatusOK, result) +} + +type skillInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` + HasMeta bool `json:"has_meta"` +} + +func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { + skillsDir := filepath.Join(s.workDir, "skills") + entries, err := os.ReadDir(skillsDir) + if err != nil { + writeJSON(w, http.StatusOK, []skillInfo{}) + return + } + result := make([]skillInfo, 0) + for _, e := range entries { + if !e.IsDir() { + continue + } + skillPath := filepath.Join(skillsDir, e.Name()) + desc := "" + if data, err := os.ReadFile(filepath.Join(skillPath, "SKILL.md")); err == nil { + for _, l := range strings.Split(string(data), "\n") { + if strings.HasPrefix(l, "description:") { + desc = strings.Trim(strings.TrimSpace(strings.TrimPrefix(l, "description:")), `"`) + break + } + } + } + _, hasMeta := os.Stat(filepath.Join(skillPath, "_meta.json")) + result = append(result, skillInfo{ + Name: e.Name(), Description: desc, Path: skillPath, HasMeta: hasMeta == nil, + }) + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleCron(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, s.cronService.ListJobs(true)) + case http.MethodPost: + var p struct { + Name string `json:"name"` + Schedule cron.CronSchedule `json:"schedule"` + Message string `json:"message"` + Command string `json:"command"` + Deliver bool `json:"deliver"` + Channel string `json:"channel"` + To string `json:"to"` + } + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + job, err := s.cronService.AddJob(p.Name, p.Schedule, p.Message, p.Deliver, p.Channel, p.To) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, job) + case http.MethodDelete: + id := r.URL.Query().Get("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing id") + return + } + if !s.cronService.RemoveJob(id) { + writeError(w, http.StatusNotFound, "job not found") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + case http.MethodPut: + id := r.URL.Query().Get("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing id") + return + } + job := s.cronService.EnableJob(id, r.URL.Query().Get("enabled") == "true") + if job == nil { + writeError(w, http.StatusNotFound, "job not found") + return + } + writeJSON(w, http.StatusOK, job) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { + type sessionMeta struct { + Key string `json:"key"` + File string `json:"file"` + Modified time.Time `json:"modified"` + Size int64 `json:"size_bytes"` + } + entries, err := os.ReadDir(filepath.Join(s.workDir, "sessions")) + if err != nil { + writeJSON(w, http.StatusOK, []sessionMeta{}) + return + } + result := make([]sessionMeta, 0) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + info, _ := e.Info() + key := strings.ReplaceAll(strings.TrimSuffix(e.Name(), ".json"), "_", ":") + result = append(result, sessionMeta{Key: key, File: e.Name(), Modified: info.ModTime(), Size: info.Size()}) + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + entries, err := os.ReadDir(filepath.Join(s.workDir, "logs")) + if err != nil { + writeJSON(w, http.StatusOK, []string{}) + return + } + var latest string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".log") { + latest = filepath.Join(s.workDir, "logs", e.Name()) + } + } + if latest == "" { + writeJSON(w, http.StatusOK, []string{}) + return + } + data, err := os.ReadFile(latest) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) > 200 { + lines = lines[len(lines)-200:] + } + writeJSON(w, http.StatusOK, lines) +} + +// ── /api/soul ───────────────────────────────────────────────────────────────── +// GET /api/soul → returns SOUL.md content +// PUT /api/soul → writes SOUL.md content +func (s *Server) handleSoul(w http.ResponseWriter, r *http.Request) { + soulPath := filepath.Join(s.workDir, "SOUL.md") + switch r.Method { + case http.MethodGet: + data, err := os.ReadFile(soulPath) + if err != nil { + // Return empty string if not found yet + writeJSON(w, http.StatusOK, map[string]string{"content": ""}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"content": string(data)}) + case http.MethodPut: + var body struct { + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if err := os.WriteFile(soulPath, []byte(body.Content), 0o644); err != nil { + writeError(w, http.StatusInternalServerError, "cannot write SOUL.md: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "saved"}) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// ── /api/skill ──────────────────────────────────────────────────────────────── +// GET /api/skill?name=X → returns SKILL.md content for skill X +// PUT /api/skill?name=X → writes SKILL.md for skill X +// POST /api/skill?name=X → creates new skill folder + SKILL.md +// DELETE /api/skill?name=X → deletes skill folder +func (s *Server) handleSkillFile(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + if name == "" { + writeError(w, http.StatusBadRequest, "missing name parameter") + return + } + // Sanitize name — no path traversal + name = strings.ReplaceAll(name, "..", "") + name = strings.ReplaceAll(name, "/", "") + skillDir := filepath.Join(s.workDir, "skills", name) + skillMd := filepath.Join(skillDir, "SKILL.md") + + switch r.Method { + case http.MethodGet: + data, err := os.ReadFile(skillMd) + if err != nil { + writeJSON(w, http.StatusOK, map[string]string{"content": ""}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"content": string(data)}) + + case http.MethodPut: + var body struct{ Content string `json:"content"` } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := os.WriteFile(skillMd, []byte(body.Content), 0o644); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "saved"}) + + case http.MethodPost: + if err := os.MkdirAll(skillDir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + var body struct{ Content string `json:"content"` } + _ = json.NewDecoder(r.Body).Decode(&body) + content := body.Content + if content == "" { + content = "# " + name + "\n\ndescription: \"Describe what this skill does\"\n\n## Instructions\n\nWrite your skill instructions here.\n" + } + if err := os.WriteFile(skillMd, []byte(content), 0o644); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "created"}) + + case http.MethodDelete: + if err := os.RemoveAll(skillDir); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} diff --git a/pkg/dashboard/ui.go b/pkg/dashboard/ui.go new file mode 100644 index 000000000..f12ee0b4e --- /dev/null +++ b/pkg/dashboard/ui.go @@ -0,0 +1,709 @@ +package dashboard + +// dashboardHTML is the embedded frontend served at /dashboard. +// Single-file SPA — no external dependencies except Google Fonts. +const dashboardHTML = ` + + + + +PicoClaw Gateway Dashboard + + + + +
+ + + + + +
+
+
Overview
+
+ +
+
+ +
+
loading...
+
+
+ +
+ + +
+ + + +` From 764e4bcb9d07114e77d7ea1c65517a3a8be14b0d Mon Sep 17 00:00:00 2001 From: Guglielmo Caponi <153330034+gcaponi@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:17:54 +0100 Subject: [PATCH 2/2] fix: support Telegram forum topics (message_thread_id routing) Telegram supergroups with forum/topics enabled were not routing replies to the correct topic. Added message_thread_id support to Send, SendPlaceholder, StartTyping, EditMessage and handleMessage. --- pkg/channels/telegram/telegram.go | 49 ++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a2035853c..2a6037de8 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -168,7 +168,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseChatIDWithThread(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -178,6 +178,14 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // Typing/placeholder handled by Manager.preSend — just send the message tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML + logger.InfoCF("telegram", "DEBUG send info", map[string]any{ + "raw_chat_id": msg.ChatID, + "parsed_chat_id": chatID, + "parsed_thread_id": threadID, + }) + if threadID != 0 { + tgMsg.MessageThreadID = threadID + } if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ @@ -197,7 +205,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - cid, err := parseChatID(chatID) + cid, _, err := parseChatIDWithThread(chatID) if err != nil { return func() {}, err } @@ -224,7 +232,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - cid, err := parseChatID(chatID) + cid, _, err := parseChatIDWithThread(chatID) if err != nil { return err } @@ -253,12 +261,16 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s text = "Thinking... 💭" } - cid, err := parseChatID(chatID) + cid, tid, err := parseChatIDWithThread(chatID) if err != nil { return "", err } - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) + phMsg := tu.Message(tu.ID(cid), text) + if tid != 0 { + phMsg.MessageThreadID = tid + } + pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err } @@ -272,7 +284,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe return channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + chatID, _, err := parseChatIDWithThread(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -380,6 +392,15 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes mediaPaths := []string{} chatIDStr := fmt.Sprintf("%d", chatID) + logger.InfoCF("telegram", "DEBUG message info", map[string]any{ + "chat_id": chatID, + "message_thread_id": message.MessageThreadID, + "chat_type": message.Chat.Type, + "is_topic_message": message.IsTopicMessage, + }) + if message.MessageThreadID != 0 { + chatIDStr = fmt.Sprintf("%d|%d", chatID, message.MessageThreadID) + } messageIDStr := fmt.Sprintf("%d", message.MessageID) scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) @@ -500,7 +521,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes peer, messageID, platformID, - fmt.Sprintf("%d", chatID), + chatIDStr, content, mediaPaths, metadata, @@ -747,3 +768,17 @@ func (c *TelegramChannel) stripBotMention(content string) string { content = re.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +func parseChatIDWithThread(chatIDStr string) (int64, int, error) { + parts := strings.SplitN(chatIDStr, "|", 2) + var id int64 + _, err := fmt.Sscanf(parts[0], "%d", &id) + if err != nil { + return 0, 0, err + } + threadID := 0 + if len(parts) == 2 { + fmt.Sscanf(parts[1], "%d", &threadID) + } + return id, threadID, nil +}