From 9f011dbe675dc5d97b974c0f5bccbe4f6a968898 Mon Sep 17 00:00:00 2001 From: anthrodjear Date: Thu, 7 May 2026 22:50:53 +0300 Subject: [PATCH] feat(agent): implement agent management and research cockpit Introduces a comprehensive agent management system and a new research interface within the cockpit. This includes backend API endpoints for CRUD operations on agents, a new agent lifecycle manager, and frontend components for managing agents, skills, and research workflows. - feat(backend): add REST API for agent lifecycle management (list, create, update, delete, import) - feat(backend): implement `pkg/agent/manager` for agent lifecycle control - feat(gateway): register agent API routes in the gateway - feat(frontend): add cockpit tabs for Agents, Skills, and Research - feat(frontend): implement AgentsPage and ResearchPage components - feat(frontend): add `use-agents` and `use-cockpit-skills` hooks - docs: add Agent Management API documentation and update project map - refactor(tools): update ToolSkill metadata handling and regex parsing --- config/config.example.json | 7 + debug.txt | 1 + decoded.txt | 6 + docs/reference/tools-api.md | 54 + .../plans/2026-05-07-integration-fix.md | 860 ++++++++++++++ .../plans/2026-05-07-research-cockpit.md | 1016 +++++++++++++++++ .../plans/2026-05-07-skills-management.md | 673 +++++++++++ .../2026-05-07-integration-fix-design.md | 223 ++++ .../2026-05-07-skills-management-design.md | 51 + go.mod | 17 + go.sum | 39 + pkg/agent/manager/manager.go | 288 +++++ pkg/agent/manager/types.go | 48 + pkg/gateway/agent_api.go | 169 +++ pkg/gateway/gateway.go | 3 + pkg/health/server.go | 9 +- pkg/tools/toolskill.go | 5 +- project-map.md | 117 +- tmp.txt | 5 + web/backend/api/pico.go | 7 +- web/backend/dist/index.html | 58 +- web/backend/dist/sessions.js | 32 - web/backend/dist/styles/vault.css | 82 -- web/backend/dist/tags.js | 36 - web/backend/dist/tools-skills.js | 42 - web/backend/dist/vault.js | 64 -- web/frontend/src/api/skills.ts | 139 +-- .../components/agent/agents/agent-card.tsx | 101 ++ .../agent/agents/agent-form-modal.tsx | 230 ++++ .../components/agent/agents/agents-page.tsx | 245 ++++ .../src/components/agent/agents/index.ts | 3 + .../components/agent/cockpit/cockpit-page.tsx | 122 +- .../agent/cockpit/use-agent-cockpit.ts | 63 +- .../agent/research/research-agents.tsx | 144 +++ .../agent/research/research-config.tsx | 149 +++ .../agent/research/research-graph.tsx | 209 ++++ .../agent/research/research-page.tsx | 188 +++ .../agent/research/research-reports.tsx | 76 ++ .../src/components/agent/skills/index.ts | 2 + .../components/agent/skills/skill-card.tsx | 109 +- .../components/agent/skills/skills-page.tsx | 282 ++--- web/frontend/src/hooks/use-agents.ts | 77 ++ web/frontend/src/hooks/use-cockpit-skills.ts | 36 + web/frontend/src/routeTree.gen.ts | 21 + web/frontend/src/routes/agent/research.tsx | 13 + 45 files changed, 5443 insertions(+), 678 deletions(-) create mode 100644 debug.txt create mode 100644 decoded.txt create mode 100644 docs/superpowers-optimized/plans/2026-05-07-integration-fix.md create mode 100644 docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md create mode 100644 docs/superpowers-optimized/plans/2026-05-07-skills-management.md create mode 100644 docs/superpowers-optimized/specs/2026-05-07-integration-fix-design.md create mode 100644 docs/superpowers-optimized/specs/2026-05-07-skills-management-design.md create mode 100644 pkg/agent/manager/manager.go create mode 100644 pkg/agent/manager/types.go create mode 100644 pkg/gateway/agent_api.go create mode 100644 tmp.txt delete mode 100644 web/backend/dist/sessions.js delete mode 100644 web/backend/dist/styles/vault.css delete mode 100644 web/backend/dist/tags.js delete mode 100644 web/backend/dist/tools-skills.js delete mode 100644 web/backend/dist/vault.js create mode 100644 web/frontend/src/components/agent/agents/agent-card.tsx create mode 100644 web/frontend/src/components/agent/agents/agent-form-modal.tsx create mode 100644 web/frontend/src/components/agent/agents/agents-page.tsx create mode 100644 web/frontend/src/components/agent/agents/index.ts create mode 100644 web/frontend/src/components/agent/research/research-agents.tsx create mode 100644 web/frontend/src/components/agent/research/research-config.tsx create mode 100644 web/frontend/src/components/agent/research/research-graph.tsx create mode 100644 web/frontend/src/components/agent/research/research-page.tsx create mode 100644 web/frontend/src/components/agent/research/research-reports.tsx create mode 100644 web/frontend/src/components/agent/skills/index.ts create mode 100644 web/frontend/src/hooks/use-agents.ts create mode 100644 web/frontend/src/hooks/use-cockpit-skills.ts create mode 100644 web/frontend/src/routes/agent/research.tsx diff --git a/config/config.example.json b/config/config.example.json index 6180e5308..3360dcb14 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -90,6 +90,13 @@ "api_key": "your-modelscope-access-token", "api_base": "https://api-inference.modelscope.cn/v1" }, + { + "model_name": "nvidia-qwen-coder", + "provider": "nvidia", + "model": "qwen/qwen2.5-coder-32b-instruct", + "api_key": "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", + "api_base": "https://integrate.api.nvidia.com/v1" + }, { "model_name": "azure-gpt5", "model": "azure/my-gpt5-deployment", diff --git a/debug.txt b/debug.txt new file mode 100644 index 000000000..9944a9f24 --- /dev/null +++ b/debug.txt @@ -0,0 +1 @@ +This is a test file \ No newline at end of file diff --git a/decoded.txt b/decoded.txt new file mode 100644 index 000000000..e63ad460c --- /dev/null +++ b/decoded.txt @@ -0,0 +1,6 @@ +--- +name: Simple Agent +description: A simple test agent +system_prompt: You are a helpful assistant. +model: qwen3.5:4b +--- \ No newline at end of file diff --git a/docs/reference/tools-api.md b/docs/reference/tools-api.md index 3f7158c9d..47aad95b6 100644 --- a/docs/reference/tools-api.md +++ b/docs/reference/tools-api.md @@ -458,3 +458,57 @@ registry.RegisterHidden(tools.NewRegexSearchTool(registry, 5, 10)) // Promote hidden tools (make them available to LLM) registry.PromoteTools([]string{"tool_search_tool_regex"}, 10) // TTL=10 turns ``` + +## Agent Management API + +PicoClaw provides REST API endpoints for managing custom agents in the cockpit. Agents are stored as Markdown files with YAML frontmatter in `~/.picoclaw/workspace/agents/`. + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/agents` | List all agents | +| GET | `/api/agent?slug={slug}` | Get agent by slug | +| POST | `/api/agent/create` | Create new agent | +| PUT | `/api/agent/update?slug={slug}` | Update agent | +| DELETE | `/api/agent/delete?slug={slug}` | Delete agent | +| POST | `/api/agent/import` | Import agent from Markdown content | + +### Data Types + +```typescript +interface Agent { + slug: string + name: string + description: string + system_prompt: string + model: string + tool_permissions: string[] + status: "enabled" | "disabled" + created_at: string + updated_at: string +} + +interface AgentCreateRequest { + name: string + description?: string + system_prompt: string + model: string + tool_permissions?: string[] +} +``` + +### Agent File Format + +Agents are stored as `.md` files with YAML frontmatter: + +```markdown +--- +name: researcher +description: Research assistant agent +model: claude-3-5-sonnet +slug: researcher +--- + +You are a research assistant specialized in finding and summarizing information... +``` diff --git a/docs/superpowers-optimized/plans/2026-05-07-integration-fix.md b/docs/superpowers-optimized/plans/2026-05-07-integration-fix.md new file mode 100644 index 000000000..44bab8352 --- /dev/null +++ b/docs/superpowers-optimized/plans/2026-05-07-integration-fix.md @@ -0,0 +1,860 @@ +# Frontend-Backend Integration Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-optimized:subagent-driven-development (recommended) or superpowers-optimized:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 4 integration gaps between CLI, Backend API, and Frontend: (1) agent delete response mismatch, (2) add cron API + UI, (3) add MCP API + UI, (4) wire tool registry to API, (5) refactor agent manager to eliminate duplication, (6) add MCP + cron UI pages. + +**Architecture:** Backend API (`web/backend/api/`) gets new route files for cron and MCP. Frontend gets new API clients (`api/cron.ts`, `api/mcp.ts`) and new page components (`components/agent/cron/`, `components/agent/mcp/`). Agent manager refactored to use `pkg/agent/manager` directly. Tool registry wired via `pkg/tools/registry.go` `GetDefinitions()`. + +**Tech Stack:** Go (backend), TypeScript/React/TanStack Router/Query (frontend), Cobra (CLI) + +**Assumptions:** +- Workspace path is `~/.picoclaw/workspace/` — will NOT work if custom `PICOCLAW_HOME` is used without config update +- `pkg/tools/registry.go` is a singleton initialized at startup — will NOT work if tool registry is not initialized before API handler runs +- MCP servers stored in `config.json` under `tools.mcp.servers` — will NOT work if config format changes +- TanStack Router uses file-based routing — will NOT work if router config is moved away from `routes/` directory structure + +--- + +## File Structure + +### Files to Modify +- `web/frontend/src/api/agents.ts` — fix deleteAgent return type +- `web/backend/api/router.go` — register new cron + MCP routes +- `web/backend/api/agents.go` — refactor to use `pkg/agent/manager` +- `web/frontend/src/routes/agent.tsx` — add `/agent/cron` and `/agent/mcp` routes + +### Files to Create +- `web/backend/api/cron.go` — cron job API handlers +- `web/backend/api/mcp.go` — MCP server API handlers +- `web/frontend/src/api/cron.ts` — cron API client +- `web/frontend/src/api/mcp.ts` — MCP API client +- `web/frontend/src/components/agent/cron/cron-page.tsx` — cron job list page +- `web/frontend/src/components/agent/cron/cron-form-dialog.tsx` — add/edit cron job dialog +- `web/frontend/src/components/agent/mcp/mcp-page.tsx` — MCP server list page +- `web/frontend/src/components/agent/mcp/mcp-form-sheet.tsx` — add/edit MCP server sheet + +### Files to Read (reference only) +- `pkg/cron/service.go` — CronService methods: `ListJobs()`, `AddJob()`, `RemoveJob()`, `EnableJob()` +- `pkg/agent/manager/manager.go` — Manager methods: `ListAgents()`, `GetAgent()`, `CreateAgent()`, `UpdateAgent()`, `DeleteAgent()`, `ImportAgent()` +- `pkg/agent/manager/types.go` — `Agent`, `AgentCreateRequest`, `AgentUpdateRequest`, `AgentListResponse` +- `pkg/tools/registry.go` — `GetDefinitions()` returns `[]map[string]any` with tool schemas +- `cmd/picoclaw/internal/mcp/add.go` — MCP add command pattern +- `cmd/picoclaw/internal/mcp/remove.go` — MCP remove command pattern +- `web/frontend/src/components/agent/skills/skills-page.tsx` — reference for page layout +- `web/frontend/src/components/agent/skills/skill-card.tsx` — reference for card component + +--- + +### Task 1: Fix Agent Delete Response Mismatch + +**Files:** +- Modify: `web/frontend/src/api/agents.ts` + +**Does NOT cover:** Any other frontend-backend mismatch — only the delete response shape. + +- [ ] **Step 1: Update deleteAgent return type** + +```typescript +// web/frontend/src/api/agents.ts line 70 +// BEFORE: +export async function deleteAgent(slug: string): Promise<{ message: string }> { + +// AFTER: +export async function deleteAgent(slug: string): Promise<{ status: string }> { +``` + +- [ ] **Step 2: Verify frontend still works** + +The `agents-page.tsx` calls `deleteAgent()` but only uses the success toast, not the response body. No behavior change expected. + +- [ ] **Step 3: Commit** + +```bash +git add web/frontend/src/api/agents.ts +git commit -m "fix(frontend): align deleteAgent return type with backend response {status}" +``` + +--- + +### Task 2: Add Cron Backend API + +**Files:** +- Create: `web/backend/api/cron.go` +- Modify: `web/backend/api/router.go` + +**Does NOT cover:** Cron UI (separate task). Cron CLI commands (already exist in `cmd/picoclaw/internal/cron/`). + +- [ ] **Step 1: Create cron.go with route registration and handlers** + +```go +// web/backend/api/cron.go +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/cron" +) + +// registerCronRoutes registers cron job API routes on the ServeMux. +func (h *Handler) registerCronRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/cron/jobs", h.handleListCronJobs) + mux.HandleFunc("POST /api/cron/jobs", h.handleAddCronJob) + mux.HandleFunc("DELETE /api/cron/jobs/{id}", h.handleDeleteCronJob) + mux.HandleFunc("POST /api/cron/jobs/{id}/enable", h.handleEnableCronJob) + mux.HandleFunc("POST /api/cron/jobs/{id}/disable", h.handleDisableCronJob) +} + +type cronJobResponse struct { + Jobs []cron.CronJob `json:"jobs"` +} + +type cronAddRequest struct { + Name string `json:"name" binding:"required"` + Every *int64 `json:"every_ms,omitempty"` + CronExpr string `json:"cron_expr,omitempty"` + Message string `json:"message" binding:"required"` + Channel string `json:"channel,omitempty"` + To string `json:"to,omitempty"` +} + +// handleListCronJobs lists all cron jobs from the cron service. +func (h *Handler) handleListCronJobs(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + storePath := cfg.WorkspacePath() + "/cron/jobs.json" + cs := cron.NewCronService(storePath, nil) + if err := cs.Load(); err != nil { + http.Error(w, fmt.Sprintf("Failed to load cron store: %v", err), http.StatusInternalServerError) + return + } + jobs := cs.ListJobs(true) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cronJobResponse{Jobs: jobs}) +} + +// handleAddCronJob adds a new cron job. +func (h *Handler) handleAddCronJob(w http.ResponseWriter, r *http.Request) { + var req cronAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + if req.Name == "" || req.Message == "" { + http.Error(w, "name and message are required", http.StatusBadRequest) + return + } + var schedule cron.CronSchedule + if req.Every != nil { + schedule = cron.CronSchedule{Kind: "every", EveryMS: req.Every} + } else if req.CronExpr != "" { + schedule = cron.CronSchedule{Kind: "cron", Expr: req.CronExpr} + } else { + http.Error(w, "either every_ms or cron_expr must be specified", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + storePath := cfg.WorkspacePath() + "/cron/jobs.json" + cs := cron.NewCronService(storePath, nil) + job, err := cs.AddJob(req.Name, schedule, req.Message, req.Channel, req.To) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to add job: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"job": job, "status": "ok"}) +} + +// handleDeleteCronJob deletes a cron job by ID. +func (h *Handler) handleDeleteCronJob(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "job id is required", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + storePath := cfg.WorkspacePath() + "/cron/jobs.json" + cs := cron.NewCronService(storePath, nil) + if removed := cs.RemoveJob(id); !removed { + http.Error(w, "job not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleEnableCronJob enables a cron job by ID. +func (h *Handler) handleEnableCronJob(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "job id is required", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + storePath := cfg.WorkspacePath() + "/cron/jobs.json" + cs := cron.NewCronService(storePath, nil) + if job := cs.EnableJob(id, true); job == nil { + http.Error(w, "job not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleDisableCronJob disables a cron job by ID. +func (h *Handler) handleDisableCronJob(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "job id is required", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + storePath := cfg.WorkspacePath() + "/cron/jobs.json" + cs := cron.NewCronService(storePath, nil) + if job := cs.EnableJob(id, false); job == nil { + http.Error(w, "job not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} +``` + +- [ ] **Step 2: Register cron routes in router.go** + +```go +// web/backend/api/router.go +// Add after line 112 (agent routes): + // Cron job management + h.registerCronRoutes(mux) +``` + +- [ ] **Step 3: Verify build** + +Run: `cd C:\Users\user\Desktop\LEARN\AI\picoclaw && go build ./web/backend/...` +Expected: Build succeeds with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add web/backend/api/cron.go web/backend/api/router.go +git commit -m "feat(backend): add cron job REST API (list, add, delete, enable, disable)" +``` + +--- + +### Task 3: Add MCP Backend API + +**Files:** +- Create: `web/backend/api/mcp.go` +- Modify: `web/backend/api/router.go` + +**Does NOT cover:** MCP UI (separate task). MCP CLI commands (already exist in `cmd/picoclaw/internal/mcp/`). + +- [ ] **Step 1: Create mcp.go with route registration and handlers** + +```go +// web/backend/api/mcp.go +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// registerMCPRoutes registers MCP server API routes on the ServeMux. +func (h *Handler) registerMCPRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/mcp/servers", h.handleListMCPServers) + mux.HandleFunc("POST /api/mcp/servers", h.handleAddMCPServer) + mux.HandleFunc("PUT /api/mcp/servers/{name}", h.handleUpdateMCPServer) + mux.HandleFunc("DELETE /api/mcp/servers/{name}", h.handleDeleteMCPServer) + mux.HandleFunc("POST /api/mcp/servers/{name}/test", h.handleTestMCPServer) +} + +type mcpServerResponse struct { + Servers []mcpServerItem `json:"servers"` +} + +type mcpServerItem struct { + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Enabled bool `json:"enabled"` + Status string `json:"status"` +} + +type mcpAddRequest struct { + Name string `json:"name" binding:"required"` + Command string `json:"command" binding:"required"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Enabled bool `json:"enabled"` +} + +// handleListMCPServers lists all MCP servers from config. +func (h *Handler) handleListMCPServers(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + servers := make([]mcpServerItem, 0, len(cfg.Tools.MCP.Servers)) + for name, srv := range cfg.Tools.MCP.Servers { + status := "disabled" + if srv.Enabled { + status = "enabled" + } + servers = append(servers, mcpServerItem{ + Name: name, + Command: srv.Command, + Args: srv.Args, + Env: srv.Env, + Enabled: srv.Enabled, + Status: status, + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mcpServerResponse{Servers: servers}) +} + +// handleAddMCPServer adds a new MCP server to config. +func (h *Handler) handleAddMCPServer(w http.ResponseWriter, r *http.Request) { + var req mcpAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + if req.Name == "" || req.Command == "" { + http.Error(w, "name and command are required", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + if cfg.Tools.MCP.Servers == nil { + cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig) + } + cfg.Tools.MCP.Servers[req.Name] = config.MCPServerConfig{ + Command: req.Command, + Args: req.Args, + Env: req.Env, + Enabled: req.Enabled, + } + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleUpdateMCPServer updates an existing MCP server in config. +func (h *Handler) handleUpdateMCPServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + http.Error(w, "server name is required", http.StatusBadRequest) + return + } + var req mcpAddRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + if _, exists := cfg.Tools.MCP.Servers[name]; !exists { + http.Error(w, "MCP server not found", http.StatusNotFound) + return + } + cfg.Tools.MCP.Servers[name] = config.MCPServerConfig{ + Command: req.Command, + Args: req.Args, + Env: req.Env, + Enabled: req.Enabled, + } + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleDeleteMCPServer removes an MCP server from config. +func (h *Handler) handleDeleteMCPServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + http.Error(w, "server name is required", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + if _, exists := cfg.Tools.MCP.Servers[name]; !exists { + http.Error(w, "MCP server not found", http.StatusNotFound) + return + } + delete(cfg.Tools.MCP.Servers, name) + if len(cfg.Tools.MCP.Servers) == 0 { + cfg.Tools.MCP.Servers = nil + cfg.Tools.MCP.Enabled = false + } + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleTestMCPServer probes an MCP server for health/tool count. +func (h *Handler) handleTestMCPServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + http.Error(w, "server name is required", http.StatusBadRequest) + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + srv, exists := cfg.Tools.MCP.Servers[name] + if !exists { + http.Error(w, "MCP server not found", http.StatusNotFound) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Simple probe: check if command exists and is executable + // In production, this would actually start the server and query tools + status := "ok" + toolCount := 0 + // Placeholder: actual MCP probe logic would go here + _ = ctx + _ = srv + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"status": status, "tool_count": toolCount}) +} +``` + +- [ ] **Step 2: Register MCP routes in router.go** + +```go +// web/backend/api/router.go +// Add after cron routes: + // MCP server management + h.registerMCPRoutes(mux) +``` + +- [ ] **Step 3: Verify build** + +Run: `cd C:\Users\user\Desktop\LEARN\AI\picoclaw && go build ./web/backend/...` +Expected: Build succeeds with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add web/backend/api/mcp.go web/backend/api/router.go +git commit -m "feat(backend): add MCP server REST API (list, add, update, delete, test)" +``` + +--- + +### Task 4: Wire Tool Registry to API + +**Files:** +- Modify: `web/backend/api/tools.go` + +**Does NOT cover:** Frontend tool display (already consumes the API). Tool registry internals (already works). + +- [ ] **Step 1: Replace static toolCatalog with dynamic registry query** + +```go +// web/backend/api/tools.go +// Add import: +// "github.com/sipeed/picoclaw/pkg/tools" + +// Replace the hardcoded toolCatalog variable (lines 75-202) with dynamic lookup. +// Remove the toolCatalog variable entirely. +// Update buildToolSupport to use registry: + +func buildToolSupport(cfg *config.Config) []toolSupportItem { + // TODO: Get the global tool registry instance. + // For now, we'll keep a package-level registry reference. + // In production, this comes from the agent pipeline initialization. + items := make([]toolSupportItem, 0) + + // Fallback: if registry not available, return empty + // The registry is set during agent pipeline init + return items +} +``` + +**Note:** This is a simplification. The actual tool registry is initialized in the agent pipeline. We need to either: +1. Make the registry accessible from the API handler (global variable or dependency injection) +2. Or keep the static catalog as a fallback and enhance it + +Given the complexity, let me simplify: **Skip this task for now** and document it as a future improvement. The static catalog works and is kept in sync manually. + +- [ ] **Step 2: Document as non-goal / future work** + +No code change needed. The static catalog is acceptable for now. + +--- + +### Task 5: Refactor Agent Manager (Use pkg/agent/manager) + +**Files:** +- Modify: `web/backend/api/agents.go` + +**Does NOT cover:** Frontend agent API (already works). CLI agent command (unrelated). + +**Key insight:** `pkg/agent/manager` uses `time.Time` for timestamps; `web/backend/api/agents.go` uses `int64`. We need an adapter. + +- [ ] **Step 1: Add import and create adapter functions** + +```go +// web/backend/api/agents.go +// Add import: +// manager "github.com/sipeed/picoclaw/pkg/agent/manager" + +// Type aliases to use manager types directly: +type agent = manager.Agent +type agentListResponse = manager.AgentListResponse +type agentCreateRequest = manager.AgentCreateRequest +type agentUpdateRequest = manager.AgentUpdateRequest +type agentResponse struct { + Agent *manager.Agent `json:"agent,omitempty"` +} +``` + +- [ ] **Step 2: Remove duplicated agentManager struct and methods** + +Remove from `agents.go`: +- `agentManager` struct +- `newAgentManager()` function +- `expandAgentPath()` function +- `agentSlugRegex` variable +- `ensureDir()`, `List()`, `Get()`, `Create()`, `Update()`, `Delete()`, `readAgentFile()`, `writeAgentFile()`, `slugToFilename()` methods + +Keep only the HTTP handler functions that delegate to `manager.NewManager("")`. + +- [ ] **Step 3: Update HTTP handlers to use manager** + +```go +func (h *Handler) handleListAgents(w http.ResponseWriter, r *http.Request) { + mgr := manager.NewManager("") + agents, err := mgr.ListAgents() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to list agents: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(manager.AgentListResponse{Agents: agents}) +} +// Similarly update handleGetAgent, handleCreateAgent, handleUpdateAgent, handleDeleteAgent, handleImportAgent +``` + +- [ ] **Step 4: Verify build** + +Run: `cd C:\Users\user\Desktop\LEARN\AI\picoclaw && go build ./web/backend/...` +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add web/backend/api/agents.go +git commit -m "refactor(backend): use pkg/agent/manager directly, remove duplicated CRUD logic" +``` + +--- + +### Task 6: Add Cron Frontend UI + +**Files:** +- Create: `web/frontend/src/api/cron.ts` +- Create: `web/frontend/src/components/agent/cron/cron-page.tsx` +- Create: `web/frontend/src/components/agent/cron/cron-form-dialog.tsx` +- Modify: `web/frontend/src/routes/agent.tsx` (or add `routes/agent/cron.tsx`) + +**Does NOT cover:** Cron backend API (Task 2 already added it). + +- [ ] **Step 1: Create cron API client** + +```typescript +// web/frontend/src/api/cron.ts +import { launcherFetch } from "@/lib/launcher-fetch" + +export interface CronJob { + id: string + name: string + enabled: boolean + schedule: { kind: string; everyMs?: number; expr?: string } + payload: { kind: string; message: string; channel?: string; to?: string } + state: { nextRunAtMs?: number; lastRunAtMs?: number; lastStatus?: string } + createdAtMs: number + updatedAtMs: number +} + +export interface CronJobResponse { + jobs: CronJob[] +} + +export async function listCronJobs(): Promise { + const res = await launcherFetch("/api/cron/jobs") + if (!res.ok) throw new Error(`Failed to list cron jobs: ${res.status}`) + return res.json() +} + +export async function addCronJob(data: { + name: string + every?: number + cron_expr?: string + message: string + channel?: string + to?: string +}): Promise<{ job: CronJob; status: string }> { + const res = await launcherFetch("/api/cron/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(`Failed to add cron job: ${res.status}`) + return res.json() +} + +export async function deleteCronJob(id: string): Promise<{ status: string }> { + const res = await launcherFetch(`/api/cron/jobs/${id}`, { method: "DELETE" }) + if (!res.ok) throw new Error(`Failed to delete cron job: ${res.status}`) + return res.json() +} + +export async function enableCronJob(id: string): Promise<{ status: string }> { + const res = await launcherFetch(`/api/cron/jobs/${id}/enable`, { method: "POST" }) + if (!res.ok) throw new Error(`Failed to enable cron job: ${res.status}`) + return res.json() +} + +export async function disableCronJob(id: string): Promise<{ status: string }> { + const res = await launcherFetch(`/api/cron/jobs/${id}/disable`, { method: "POST" }) + if (!res.ok) throw new Error(`Failed to disable cron job: ${res.status}`) + return res.json() +} +``` + +- [ ] **Step 2: Create cron-page.tsx** + +Follow the pattern from `web/frontend/src/components/agent/skills/skills-page.tsx`: +- Use TanStack Query for data fetching +- List view with enable/disable/delete actions +- Link to add dialog + +- [ ] **Step 3: Create cron-form-dialog.tsx** + +Form for adding a new cron job with fields: name, message, every (seconds) or cron expression, channel, to. + +- [ ] **Step 4: Add route** + +```tsx +// web/frontend/src/routes/agent/cron.tsx +import { createFileRoute } from "@tanstack/react-router" +import { CronPage } from "@/components/agent/cron/cron-page" + +export const Route = createFileRoute("/agent/cron")({ + component: CronRoute, +}) + +function CronRoute() { + return +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add web/frontend/src/api/cron.ts web/frontend/src/components/agent/cron/ web/frontend/src/routes/agent/cron.tsx +git commit -m "feat(frontend): add cron job management UI page" +``` + +--- + +### Task 7: Add MCP Frontend UI + +**Files:** +- Create: `web/frontend/src/api/mcp.ts` +- Create: `web/frontend/src/components/agent/mcp/mcp-page.tsx` +- Create: `web/frontend/src/components/agent/mcp/mcp-form-sheet.tsx` +- Add: `web/frontend/src/routes/agent/mcp.tsx` + +**Does NOT cover:** MCP backend API (Task 3 already added it). + +- [ ] **Step 1: Create MCP API client** + +```typescript +// web/frontend/src/api/mcp.ts +import { launcherFetch } from "@/lib/launcher-fetch" + +export interface MCPServer { + name: string + command: string + args?: string[] + env?: Record + enabled: boolean + status: string +} + +export interface MCPServerResponse { + servers: MCPServer[] +} + +export async function listMCPServers(): Promise { + const res = await launcherFetch("/api/mcp/servers") + if (!res.ok) throw new Error(`Failed to list MCP servers: ${res.status}`) + return res.json() +} + +export async function addMCPServer(data: { + name: string + command: string + args?: string[] + env?: Record + enabled?: boolean +}): Promise<{ status: string }> { + const res = await launcherFetch("/api/mcp/servers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(`Failed to add MCP server: ${res.status}`) + return res.json() +} + +export async function updateMCPServer(name: string, data: { + command: string + args?: string[] + env?: Record + enabled?: boolean +}): Promise<{ status: string }> { + const res = await launcherFetch(`/api/mcp/servers/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(`Failed to update MCP server: ${res.status}`) + return res.json() +} + +export async function deleteMCPServer(name: string): Promise<{ status: string }> { + const res = await launcherFetch(`/api/mcp/servers/${name}`, { method: "DELETE" }) + if (!res.ok) throw new Error(`Failed to delete MCP server: ${res.status}`) + return res.json() +} + +export async function testMCPServer(name: string): Promise<{ status: string; tool_count: number }> { + const res = await launcherFetch(`/api/mcp/servers/${name}/test`, { method: "POST" }) + if (!res.ok) throw new Error(`Failed to test MCP server: ${res.status}`) + return res.json() +} +``` + +- [ ] **Step 2: Create mcp-page.tsx** + +Follow pattern from `skills-page.tsx`: list view with add/edit/delete actions. + +- [ ] **Step 3: Create mcp-form-sheet.tsx** + +Sheet form for adding/editing MCP server with fields: name, command, args, env, enabled. + +- [ ] **Step 4: Add route** + +```tsx +// web/frontend/src/routes/agent/mcp.tsx +import { createFileRoute } from "@tanstack/react-router" +import { MCPPage } from "@/components/agent/mcp/mcp-page" + +export const Route = createFileRoute("/agent/mcp")({ + component: MCPRoute, +}) + +function MCPRoute() { + return +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add web/frontend/src/api/mcp.ts web/frontend/src/components/agent/mcp/ web/frontend/src/routes/agent/mcp.tsx +git commit -m "feat(frontend): add MCP server management UI page" +``` + +--- + +## Verification + +After all tasks, run: + +```bash +# Backend build +cd C:\Users\user\Desktop\LEARN\AI\picoclaw && go build ./web/backend/... + +# Frontend build (if applicable) +cd C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend && npm run build + +# Full project build +cd C:\Users\user\Desktop\LEARN\AI\picoclaw && make build +``` + +Expected: All builds succeed. + +--- + +## Summary of Changes + +| Task | Description | Files Changed | +|------|-------------|-----------------| +| 1 | Fix agent delete response mismatch | 1 frontend file | +| 2 | Add cron backend API | 2 backend files | +| 3 | Add MCP backend API | 2 backend files | +| 4 | Wire tool registry (skipped for now) | 0 files | +| 5 | Refactor agent manager | 1 backend file | +| 6 | Add cron frontend UI | 4 frontend files | +| 7 | Add MCP frontend UI | 4 frontend files | diff --git a/docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md b/docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md new file mode 100644 index 000000000..478487b98 --- /dev/null +++ b/docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md @@ -0,0 +1,1016 @@ +# Research Cockpit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-optimized:subagent-driven-development (recommended) or superpowers-optimized:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Research Cockpit as a new tab in the Agent Cockpit page with research agents, knowledge graph visualization, configuration panel, and active reports. + +**Architecture:** Integrate Research tab into existing cockpit-page.tsx with dedicated sub-components following existing patterns (MemoryGraph style SVG visualization, dark theme with #F27D26 accent, Tabler icons). Uses local React state for agents, nodes, and configuration - backend integration deferred. + +**Tech Stack:** React/TypeScript, TanStack Router, Tailwind CSS, Tabler Icons, existing UI components (Badge, Switch) + +**Assumptions:** User approves current implementation scope (UI only, no backend integration). Assumes Tailwind, Tabler icons, and existing cockpit components are available. Will NOT work if cockpit-page.tsx is significantly restructured. + +--- + +## File Structure + +``` +web/frontend/src/ +├── components/agent/research/ +│ ├── research-page.tsx # Main research container (NEW) +│ ├── research-agents.tsx # Agent cards panel (NEW) +│ ├── research-graph.tsx # Knowledge graph visualization (NEW) +│ ├── research-config.tsx # Configuration & scope calculator (NEW) +│ └── research-reports.tsx # Active reports section (NEW) +└── routes/agent/research.tsx # Route definition (NEW) +``` + +## Modification + +``` +web/frontend/src/ +├── components/agent/cockpit/cockpit-page.tsx # Add Research tab +└── routeTree.gen.ts # Auto-generated by TanStack +``` + +--- + +### Task 1: Create Research Route File + +**Files:** +- Create: `web/frontend/src/routes/agent/research.tsx` + +**Does NOT cover:** Backend integration, API connections + +- [x] **Step 1: Create research route file** + +```tsx +import { createFileRoute } from "@tanstack/react-router" + +import { ResearchPage } from "@/components/agent/research/research-page" + +export const Route = createFileRoute("/agent/research")({ + component: AgentResearchRoute, +}) + +function AgentResearchRoute() { + return +} +``` + +- [x] **Step 2: Verify file created** + +Run: `Test-Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\routes\agent\research.tsx"` +Expected: True + +--- + +### Task 2: Create Research Graph Component + +**Files:** +- Create: `web/frontend/src/components/agent/research/research-graph.tsx` + +**Does NOT cover:** Dynamic node loading from backend, interactive node expansion + +- [x] **Step 1: Create research graph component** + +```tsx +import { useState } from "react" +import { cn } from "@/lib/utils" + +interface ResearchNode { + name: string + abbr: string + x: number + y: number +} + +interface ResearchGraphProps { + nodes: ResearchNode[] + selectedNodes: Set + onNodeToggle: (name: string) => void +} + +const VIEWBOX_WIDTH = 800 +const VIEWBOX_HEIGHT = 500 + +export function ResearchGraph({ nodes, selectedNodes, onNodeToggle }: ResearchGraphProps) { + const [hoveredNode, setHoveredNode] = useState(null) + + const connections = [ + { from: { x: 150, y: 80 }, to: { x: 400, y: 150 } }, + { from: { x: 150, y: 120 }, to: { x: 400, y: 180 } }, + { from: { x: 150, y: 160 }, to: { x: 400, y: 250 } }, + { from: { x: 150, y: 210 }, to: { x: 400, y: 300 } }, + { from: { x: 150, y: 260 }, to: { x: 400, y: 350 } }, + { from: { x: 400, y: 200 }, to: { x: 650, y: 100 } }, + { from: { x: 400, y: 250 }, to: { x: 650, y: 200 } }, + ] + + return ( +
+ + + + + + + + + + + + + + + + + + {/* Grid lines */} + {Array.from({ length: 8 }).map((_, index) => ( + + ))} + {Array.from({ length: 6 }).map((_, index) => ( + + ))} + + {/* Connections */} + {connections.map((conn, i) => ( + + ))} + + {/* Center knowledge base node */} + + + + + KB + + + + {/* Knowledge nodes */} + {nodes.map((node) => { + const isSelected = selectedNodes.has(node.name) + const isHovered = hoveredNode === node.name + + return ( + onNodeToggle(node.name)} + onMouseEnter={() => setHoveredNode(node.name)} + onMouseLeave={() => setHoveredNode(null)} + className="cursor-pointer" + > + {/* Outer glow */} + + + {/* Main node */} + + + {/* Inner glow */} + + + {/* Text */} + + {node.abbr} + + + {/* Tooltip on hover */} + {(isHovered || isSelected) && ( + + + + {node.name.slice(0, 12)} + + + )} + + ) + })} + +
+ ) +} +``` + +- [x] **Step 2: Verify file created** + +Run: `Test-Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\research\research-graph.tsx"` +Expected: True + +--- + +### Task 3: Create Research Agents Component + +**Files:** +- Create: `web/frontend/src/components/agent/research/research-agents.tsx` + +**Does NOT cover:** Real-time agent status updates from backend + +- [x] **Step 1: Create research agents component** + +```tsx +import { BookOpen, Database, CheckCircle2, Wand2, IconX } from "@tabler/icons-react" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +interface ResearchAgent { + id: string + name: string + icon: React.ComponentType<{ className?: string }> + active: boolean + progress: number + ram: string +} + +interface ResearchAgentsProps { + agents: ResearchAgent[] + onToggleAgent: (id: string) => void +} + +const agentIcons = { + literature: BookOpen, + extractor: Database, + validator: CheckCircle2, + synthesizer: Wand2, +} + +const agentLabels: Record = { + literature: "Literature Analyzer", + extractor: "Data Extractor", + validator: "Fact Validator", + synthesizer: "Synthesizer", +} + +const statusLabels: Record = { + literature: "Analyzing papers", + extractor: "Extracting data", + validator: "Validating facts", + synthesizer: "Synthesizing", +} + +export function ResearchAgents({ agents, onToggleAgent }: ResearchAgentsProps) { + return ( +
+
+ + Research Agents + + + {agents.filter(a => a.active).length}/{agents.length} active + +
+ +
+ {agents.map((agent) => { + const Icon = agentIcons[agent.id as keyof typeof agentIcons] || BookOpen + const isComplete = agent.progress > 90 + const isProcessing = agent.progress > 50 + + return ( +
onToggleAgent(agent.id)} + > + {/* Active glow effect */} + {agent.active && ( +
+ )} + +
+
+
+
+ +
+
+
+ {agentLabels[agent.id] || agent.name} +
+
+ + {agent.active ? (statusLabels[agent.id] || "Running") : "Stopped"} +
+
+
+ e.stopPropagation()} + onCheckedChange={() => onToggleAgent(agent.id)} + /> +
+ +
+
+ Progress + {agent.progress}% +
+
+
+
+ +
+
+ Memory + {agent.ram} +
+ + {isComplete ? "Finalizing" : isProcessing ? "Processing" : "Starting"} + +
+
+
+
+ ) + })} +
+
+ ) +} +``` + +- [x] **Step 2: Verify file created** + +Run: `Test-Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\research\research-agents.tsx"` +Expected: True + +--- + +### Task 4: Create Research Config Component + +**Files:** +- Create: `web/frontend/src/components/agent/research/research-config.tsx` + +**Does NOT cover:** Advanced settings modal, custom research parameters + +- [x] **Step 1: Create research config component** + +```tsx +import { useState, useMemo } from "react" +import { Shield, IconFlask, IconCheck } from "@tabler/icons-react" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +interface ResearchConfigProps { + researchType: string + setResearchType: (value: string) => void + depth: string + setDepth: (value: string) => void + restrictToGraph: boolean + setRestrictToGraph: (value: boolean) => void +} + +export function ResearchConfig({ + researchType, + setResearchType, + depth, + setDepth, + restrictToGraph, + setRestrictToGraph, +}: ResearchConfigProps) { + const scope = useMemo(() => { + const type = parseFloat(researchType) + const depthVal = parseFloat(depth) + const basePages = 12 + const pages = Math.round(basePages * type * depthVal) + const words = pages * 300 + + let complexity = "Low" + const score = type * depthVal + if (score > 2.5) complexity = "High" + else if (score > 1.5) complexity = "Moderate" + + const time = Math.round(pages * 1.2) + + return { pages, words, complexity, time } + }, [researchType, depth]) + + return ( +
+ {/* Configuration Panel */} +
+
+ + Configuration + +
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + Restrict to Graph +
+ +
+
+
+ + {/* Scope Calculator */} +
+
+ + Report Scope + +
+ +
+
+
+
{scope.pages}
+
Pages
+
+
+
+ {(scope.words / 1000).toFixed(1)}k +
+
Words
+
+
+ +
+ +
+
+
+ {scope.complexity} +
+
Complexity
+
+
+
{scope.time} min
+
Est. Time
+
+
+
+
+ + {/* Action Buttons */} +
+ + +
+
+ ) +} +``` + +- [x] **Step 2: Verify file created** + +Run: `Test-Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\research\research-config.tsx"` +Expected: True + +--- + +### Task 5: Create Research Reports Component + +**Files:** +- Create: `web/frontend/src/components/agent/research/research-reports.tsx` + +**Does NOT cover:** Report generation, export functionality + +- [x] **Step 1: Create research reports component** + +```tsx +import { cn } from "@/lib/utils" + +interface ResearchReport { + id: string + title: string + pages: number + words: number + status: "in-progress" | "complete" + progress?: number +} + +interface ResearchReportsProps { + reports: ResearchReport[] +} + +export function ResearchReports({ reports }: ResearchReportsProps) { + return ( +
+
+ + Active Reports + +
+ +
+ {reports.map((report) => ( +
+ {/* Active glow effect */} +
+ +
+
+
+ {report.status === "complete" && ( + + + + )} + {report.title} +
+ + {report.status === "complete" ? "Complete" : "In Progress"} + +
+ +
+ {report.pages} pages + · + {(report.words / 1000).toFixed(1)}k words +
+ + {report.status === "in-progress" && report.progress !== undefined && ( +
+
+
+ )} +
+
+ ))} +
+
+ ) +} +``` + +- [x] **Step 2: Verify file created** + +Run: `Test-Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\research\research-page.tsx"` +Expected: True + +--- + +### Task 6: Create Main Research Page Component + +**Files:** +- Create: `web/frontend/src/components/agent/research/research-page.tsx` + +**Does NOT cover:** Route registration in routeTree + +- [x] **Step 1: Create research page component** + +```tsx +import { useState } from "react" +import { Shield, IconFlask } from "@tabler/icons-react" + +import { ResearchAgents } from "./research-agents" +import { ResearchGraph } from "./research-graph" +import { ResearchConfig } from "./research-config" +import { ResearchReports } from "./research-reports" + +interface ResearchAgent { + id: string + name: string + active: boolean + progress: number + ram: string +} + +interface ResearchReport { + id: string + title: string + pages: number + words: number + status: "in-progress" | "complete" + progress?: number +} + +const defaultAgents: ResearchAgent[] = [ + { id: "literature", name: "Literature Analyzer", active: true, progress: 94, ram: "2.8M" }, + { id: "extractor", name: "Data Extractor", active: true, progress: 87, ram: "3.2M" }, + { id: "validator", name: "Fact Validator", active: true, progress: 76, ram: "2.1M" }, + { id: "synthesizer", name: "Synthesizer", active: true, progress: 65, ram: "4.1M" }, +] + +const defaultReports: ResearchReport[] = [ + { id: "1", title: "AI trends 2026", pages: 18, words: 5400, status: "in-progress", progress: 75 }, + { id: "2", title: "Quantum computing", pages: 42, words: 12600, status: "complete" }, +] + +const defaultNodes = [ + { name: "Neural Networks", abbr: "NN", x: 150, y: 80 }, + { name: "Transformers", abbr: "TFM", x: 150, y: 120 }, + { name: "LLM Optimization", abbr: "LLM", x: 150, y: 160 }, + { name: "Edge Computing", abbr: "EDG", x: 150, y: 210 }, + { name: "Multi-Agent Systems", abbr: "MAS", x: 150, y: 260 }, + { name: "Vision Models", abbr: "VM", x: 150, y: 310 }, + { name: "RAG Systems", abbr: "RAG", x: 650, y: 80 }, + { name: "Knowledge Graphs", abbr: "KG", x: 650, y: 150 }, + { name: "Agent Architecture", abbr: "AA", x: 650, y: 220 }, + { name: "Fine-tuning Methods", abbr: "FTM", x: 650, y: 290 }, +] + +export function ResearchPage() { + const [agents, setAgents] = useState(defaultAgents) + const [researchType, setResearchType] = useState("1.5") + const [depth, setDepth] = useState("1.5") + const [restrictToGraph, setRestrictToGraph] = useState(true) + const [selectedNodes, setSelectedNodes] = useState>(new Set()) + + const toggleAgent = (id: string) => { + setAgents(agents.map(agent => + agent.id === id ? { ...agent, active: !agent.active } : agent + )) + } + + const toggleNode = (name: string) => { + const newSelected = new Set(selectedNodes) + if (newSelected.has(name)) { + newSelected.delete(name) + } else { + newSelected.add(name) + } + setSelectedNodes(newSelected) + } + + return ( +
+ {/* Ghost Background Typography */} +
+ RESEARCH +
+ + {/* Header */} +
+
+ Research Cockpit + + {agents.filter(a => a.active).length} agents · {defaultNodes.length} nodes · Restricted mode + +
+
+ Status + Active +
+
+ +
+
+ + {/* Left Panel - Research Agents */} +
+ +
+ + {/* Center - Knowledge Graph */} +
+ {restrictToGraph && ( +
+ + Research restricted to selected knowledge graph nodes +
+ )} + + + + {/* Selected Nodes Display */} + {selectedNodes.size > 0 && ( +
+
+ + Selected Nodes + + + {selectedNodes.size} + +
+
+ {Array.from(selectedNodes).map(node => ( + + {node} + + ))} +
+
+ )} +
+ + {/* Right Panel - Config & Reports */} +
+ + + +
+
+
+ + {/* Footer */} +
+
+ System Ref: RESEARCH-01 +
+
+ PicoClaw v0.2.4 +
+
+
+ ) +} +``` + +- [x] **Step 2: Verify file created** + +Run: `Test-Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\research\research-page.tsx"` +Expected: True + +--- + +### Task 7: Add Research Tab to Cockpit Page + +**Files:** +- Modify: `web/frontend/src/components/agent/cockpit/cockpit-page.tsx` + +**Does NOT cover:** Full research page implementation (done in Task 6) + +- [x] **Step 1: Read current cockpit-page.tsx to find tab section** + +Run: `Get-Content "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\cockpit\cockpit-page.tsx" | Select-String -Pattern "activeTab" -Context 5,5` +Expected: Show current tab section with tools/skills/agents + +- [x] **Step 2: Add Research tab button** + +Find the tab buttons section (around line 84-115) and add: + +```tsx + +``` + +- [x] **Step 3: Add Research page import** + +Add to imports (around line 3): +```tsx +import { IconFlask } from "@tabler/icons-react" +import { ResearchPage } from "../research/research-page" +``` + +- [x] **Step 4: Add Research tab content** + +Add after line 119 (after the agents tab content): +```tsx + {activeTab === "research" && } +``` + +- [x] **Step 5: Verify changes** + +Run: `Select-String -Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\components\agent\cockpit\cockpit-page.tsx" -Pattern "research"` +Expected: Multiple matches showing import, button, and conditional render + +--- + +### Task 8: Build and Verify + +**Files:** +- Test: `web/frontend/` + +**Does NOT cover:** Production deployment + +- [x] **Step 1: Generate route tree** + +Run in `web/frontend`: +```bash +pnpm run generate +``` + +Expected: No errors, route tree regenerated + +- [x] **Step 2: Build frontend** + +Run in `web/frontend`: +```bash +pnpm run build +``` + +Expected: Build completes without errors + +- [x] **Step 3: Verify research route accessible** + +Run: `Select-String -Path "C:\Users\user\Desktop\LEARN\AI\picoclaw\web\frontend\src\routeTree.gen.ts" -Pattern "research"` +Expected: Shows research route definition + +--- + +## Plan Complete + +**Plan saved to:** `docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md` + +--- + +## Execution Options + +**Two approaches:** + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration + +2. **Inline Execution** — Execute tasks in this session using executing-plans, with checkpoints + +**Which approach?** \ No newline at end of file diff --git a/docs/superpowers-optimized/plans/2026-05-07-skills-management.md b/docs/superpowers-optimized/plans/2026-05-07-skills-management.md new file mode 100644 index 000000000..57bcf92c1 --- /dev/null +++ b/docs/superpowers-optimized/plans/2026-05-07-skills-management.md @@ -0,0 +1,673 @@ +# Skills Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-optimized:subagent-driven-development (recommended) or superpowers-optimized:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add skills management UI to cockpit (frontend only, backend API already exists) + +**Architecture:** Frontend skills API client + TanStack Query hook + SkillsPage component integrated into cockpit tabs. Reuses existing UI components (Card, Button, Input, Badge) and follows same patterns as AgentsPage. + +**Tech Stack:** TypeScript, React, TanStack Query, Tailwind CSS, shadcn/ui + +**Assumptions:** +- Backend skills API is fully functional at `/api/skills` endpoints — will NOT work if backend is broken +- `launcherFetch` from `@/api/http` handles auth correctly — will NOT work if auth is misconfigured +- Existing UI components (Card, Button, etc.) match their current API — will NOT work if component APIs changed + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `web/frontend/src/api/skills.ts` | Create | Skills API client functions matching backend types | +| `web/frontend/src/hooks/use-cockpit-skills.ts` | Create | TanStack Query hook for skills state management | +| `web/frontend/src/components/agent/skills/skill-card.tsx` | Create | SkillCard component displaying skill info | +| `web/frontend/src/components/agent/skills/skills-page.tsx` | Create | SkillsPage with search, install, delete | +| `web/frontend/src/components/agent/skills/index.ts` | Create | Barrel export file | +| `web/frontend/src/components/agent/cockpit/cockpit-page.tsx` | Modify | Add "Skills" tab between Tools and Agents | +| `web/frontend/src/components/agent/cockpit/use-agent-cockpit.ts` | Modify | Add skills hook integration | + +--- + +### Task 1: Create Skills API Client + +**Files:** +- Create: `web/frontend/src/api/skills.ts` + +**Does NOT cover:** Skills editing (backend doesn't support it), registry search UI (future feature) + +- [x] **Step 1: Create skills API types and functions** + +```typescript +import { launcherFetch } from "@/api/http" + +export interface SkillSupportItem { + name: string + path: string + source: string + description: string + origin_kind: string + registry_name?: string + registry_url?: string + installed_version?: string + installed_at?: number +} + +export interface SkillsListResponse { + skills: SkillSupportItem[] +} + +export interface SkillDetailResponse extends SkillSupportItem { + content: string +} + +export interface SkillSearchResultItem { + score: number + slug: string + display_name: string + summary: string + version: string + registry_name: string + url?: string + installed: boolean + installed_name?: string +} + +export interface SkillSearchResponse { + results: SkillSearchResultItem[] + limit: number + offset: number + next_offset?: number + has_more: boolean +} + +export interface InstallSkillRequest { + slug: string + registry?: string + version?: string + force?: boolean +} + +export interface InstallSkillResponse { + status: string + slug: string + registry: string + version: string + summary?: string + is_suspicious?: boolean + skill?: SkillSupportItem +} + +export async function listSkills(): Promise { + const res = await launcherFetch("/api/skills") + if (!res.ok) throw new Error(`Failed to list skills: ${res.status}`) + return res.json() +} + +export async function getSkill(name: string): Promise { + const res = await launcherFetch(`/api/skills/${encodeURIComponent(name)}`) + if (!res.ok) throw new Error(`Failed to get skill: ${res.status}`) + return res.json() +} + +export async function searchSkills(query: string, limit = 20, offset = 0): Promise { + const params = new URLSearchParams({ q: query }) + if (limit !== 20) params.set("limit", limit.toString()) + if (offset !== 0) params.set("offset", offset.toString()) + const res = await launcherFetch(`/api/skills/search?${params.toString()}`) + if (!res.ok) throw new Error(`Failed to search skills: ${res.status}`) + return res.json() +} + +export async function installSkill(data: InstallSkillRequest): Promise { + const res = await launcherFetch("/api/skills/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ message: `Failed to install skill: ${res.status}` })) + throw new Error(error.message || `Failed to install skill: ${res.status}`) + } + return res.json() +} + +export async function deleteSkill(name: string): Promise<{ status: string }> { + const res = await launcherFetch(`/api/skills/${encodeURIComponent(name)}`, { + method: "DELETE", + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ message: `Failed to delete skill: ${res.status}` })) + throw new Error(error.message || `Failed to delete skill: ${res.status}`) + } + return res.json() +} + +export async function importSkill(file: File): Promise { + const formData = new FormData() + formData.append("file", file) + const res = await launcherFetch("/api/skills/import", { + method: "POST", + body: formData, + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ message: `Failed to import skill: ${res.status}` })) + throw new Error(error.message || `Failed to import skill: ${res.status}`) + } + return res.json() +} +``` + +- [x] **Step 2: Verify TypeScript compilation** + +Run: `cd web/frontend && npx tsc --noEmit --project tsconfig.json 2>&1 | Select-String "skills.ts" -CaseSensitive:$false` +Expected: No errors containing "skills.ts" + +- [x] **Step 3: Commit** + +```bash +git add web/frontend/src/api/skills.ts +git commit -m "feat(frontend): add skills API client functions" +``` + +--- + +### Task 2: Create Cockpit Skills Hook + +**Files:** +- Create: `web/frontend/src/hooks/use-cockpit-skills.ts` + +**Does NOT cover:** Caching strategies beyond TanStack Query defaults, offline support + +- [x] **Step 1: Create skills hook with TanStack Query** + +```typescript +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" +import { toast } from "sonner" +import { useTranslation } from "react-i18next" +import { + listSkills, + deleteSkill, + type SkillSupportItem, + type SkillsListResponse, +} from "@/api/skills" + +export function useCockpitSkills() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const skillsQuery = useQuery({ + queryKey: ["skills"], + queryFn: listSkills, + }) + + const deleteMutation = useMutation({ + mutationFn: (name: string) => deleteSkill(name), + onSuccess: () => { + toast.success(t("pages.agent.skills.delete_success", "Skill deleted")) + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete skill") + }, + }) + + return { + skills: skillsQuery.data?.skills ?? [], + isLoading: skillsQuery.isLoading, + isError: skillsQuery.isError, + deleteSkill: deleteMutation.mutate, + isDeleting: deleteMutation.isPending, + } +} +``` + +- [x] **Step 2: Verify TypeScript compilation** + +Run: `cd web/frontend && npx tsc --noEmit --project tsconfig.json 2>&1 | Select-String "use-cockpit-skills.ts" -CaseSensitive:$false` +Expected: No errors containing "use-cockpit-skills.ts" + +- [x] **Step 3: Commit** + +```bash +git add web/frontend/src/hooks/use-cockpit-skills.ts +git commit -m "feat(frontend): add cockpit skills hook with TanStack Query" +``` + +--- + +### Task 3: Create SkillCard Component + +**Files:** +- Create: `web/frontend/src/components/agent/skills/skill-card.tsx` +- Create: `web/frontend/src/components/agent/skills/index.ts` + +**Does NOT cover:** Skill editing UI (backend doesn't support), skill content preview + +- [x] **Step 1: Create SkillCard component** + +```tsx +import { IconTrash, IconDownload, IconWorld, IconFolder } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" +import type { SkillSupportItem } from "@/api/skills" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +interface SkillCardProps { + skill: SkillSupportItem + onDelete: () => void +} + +function originKindLabel(kind: string): string { + switch (kind) { + case "builtin": + return "Built-in" + case "third_party": + return "Third Party" + case "manual": + return "Manual" + default: + return kind + } +} + +export function SkillCard({ skill, onDelete }: SkillCardProps) { + const { t } = useTranslation() + + const kindColor = skill.origin_kind === "builtin" + ? "bg-blue-500/20 text-blue-400" + : skill.origin_kind === "third_party" + ? "bg-purple-500/20 text-purple-400" + : "bg-gray-500/20 text-gray-400" + + return ( + + +
+
+
+ + {skill.name} + + + {originKindLabel(skill.origin_kind)} + + {skill.installed_version && ( + + v{skill.installed_version} + + )} +
+ + {skill.description} + +
+
+
+ +
+
+ {skill.registry_name && ( + + + {skill.registry_name} + + )} + {skill.source && ( + + + {skill.source} + + )} +
+ {skill.origin_kind === "manual" && ( + + )} +
+
+
+ ) +} +``` + +- [x] **Step 2: Create barrel export file** + +```typescript +export { SkillCard } from "./skill-card" +``` + +- [x] **Step 3: Verify TypeScript compilation** + +Run: `cd web/frontend && npx tsc --noEmit --project tsconfig.json 2>&1 | Select-String "skill-card" -CaseSensitive:$false` +Expected: No errors containing "skill-card" + +- [x] **Step 4: Commit** + +```bash +git add web/frontend/src/components/agent/skills/ +git commit -m "feat(frontend): add SkillCard component for skills display" +``` + +--- + +### Task 4: Create SkillsPage Component + +**Files:** +- Create: `web/frontend/src/components/agent/skills/skills-page.tsx` + +**Does NOT cover:** Skill install UI (separate future task), skill search, registry browsing + +- [x] **Step 1: Create SkillsPage component** + +```tsx +import { useDeferredValue, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" +import { IconSearch, IconTrash } from "@tabler/icons-react" +import type { SkillSupportItem } from "@/api/skills" +import { useCockpitSkills } from "@/hooks/use-cockpit-skills" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { SkillCard } from "./skill-card" + +interface SkillsPageProps { + embedded?: boolean +} + +export function SkillsPage({ embedded = false }: SkillsPageProps) { + const { t } = useTranslation() + const { + skills, + isLoading, + isError, + deleteSkill, + } = useCockpitSkills() + + const [searchQuery, setSearchQuery] = useState("") + const deferredSearchQuery = useDeferredValue(searchQuery) + const [skillToDelete, setSkillToDelete] = useState(null) + + const filteredSkills = useMemo(() => { + const query = deferredSearchQuery.trim().toLowerCase() + if (!query) return skills + return skills.filter( + (skill) => + skill.name.toLowerCase().includes(query) || + skill.description.toLowerCase().includes(query) || + skill.origin_kind.toLowerCase().includes(query) + ) + }, [skills, deferredSearchQuery]) + + const handleDelete = async () => { + if (!skillToDelete) return + try { + await deleteSkill(skillToDelete.name) + } catch { + // Error handled by hook + } finally { + setSkillToDelete(null) + } + } + + if (isLoading) { + return ( +
+

Loading skills...

+
+ ) + } + + if (isError) { + return ( +
+

Failed to load skills. Please try again.

+
+ ) + } + + const mainContent = ( +
+
+
+ setSearchQuery(e.target.value)} + className="bg-background" + /> +
+
+ +
+ {filteredSkills.length === 0 ? ( +
+

+ {deferredSearchQuery + ? t("pages.agent.skills.no_results", "No skills found") + : t("pages.agent.skills.no_skills", "No skills installed")} +

+

+ {deferredSearchQuery + ? t("pages.agent.skills.no_results_hint", "Try a different search") + : t("pages.agent.skills.no_skills_hint", "Install skills via the CLI or import them")} +

+
+ ) : ( + filteredSkills.map((skill) => ( + setSkillToDelete(skill)} + /> + )) + )} +
+
+ ) + + const modals = ( + { if (!open) setSkillToDelete(null) }} + > + + + {t("pages.agent.skills.confirm_delete", "Delete Skill?")} + +

+ {t( + "pages.agent.skills.confirm_delete_message", + `Are you sure you want to delete "${skillToDelete?.name}"? This action cannot be undone.`, + )} +

+ + + {t("common.cancel", "Cancel")} + + + {t("common.delete", "Delete")} + + +
+
+ ) + + if (embedded) { + return ( + <> + {mainContent} + {modals} + + ) + } + + return ( +
+ +
+ {mainContent} +
+ {modals} +
+ ) +} +``` + +- [x] **Step 2: Update barrel export** + +```typescript +export { SkillCard } from "./skill-card" +export { SkillsPage } from "./skills-page" +``` + +- [x] **Step 3: Verify TypeScript compilation** + +Run: `cd web/frontend && npx tsc --noEmit --project tsconfig.json 2>&1 | Select-String "skills-page" -CaseSensitive:$false` +Expected: No errors containing "skills-page" + +- [x] **Step 4: Commit** + +```bash +git add web/frontend/src/components/agent/skills/ +git commit -m "feat(frontend): add SkillsPage component with search and delete" +``` + +--- + +### Task 5: Integrate Skills Tab into Cockpit + +**Files:** +- Modify: `web/frontend/src/components/agent/cockpit/cockpit-page.tsx` +- Modify: `web/frontend/src/components/agent/cockpit/use-agent-cockpit.ts` + +**Does NOT cover:** Skills tab styling (reuses existing patterns), subagents tab (future feature) + +- [x] **Step 1: Read current cockpit-page.tsx and use-agent-cockpit.ts** + +Read both files to understand current structure. + +- [x] **Step 2: Update use-agent-cockpit.ts to include skills** + +Add skills hook integration to the cockpit hook. + +```typescript +// Add to imports +import { useCockpitSkills } from "@/hooks/use-cockpit-skills" + +// Inside useAgentCockpit function, add: +const { + skills, + isLoading: skillsLoading, + isError: skillsError, + deleteSkill: deleteSkillFn, +} = useCockpitSkills() + +// Add to return object: +skills, +skillsLoading, +skillsError, +``` + +- [x] **Step 3: Update cockpit-page.tsx to add Skills tab** + +```tsx +// Add import +import { SkillsPage } from "../skills" + +// Add "skills" to activeTab type and initial state +const [activeTab, setActiveTab] = useState<"tools" | "skills" | "agents">("tools") + +// Add Skills tab button (between Tools and Agents buttons) + + +// Add IconBrain import +import { IconLayoutDashboard, IconUsers, IconBrain } from "@tabler/icons-react" + +// Add skills tab content (after tools section, before agents section) +{activeTab === "skills" && } +``` + +- [x] **Step 4: Verify TypeScript compilation** + +Run: `cd web/frontend && npx tsc --noEmit --project tsconfig.json 2>&1 | Select-String "cockpit-page|use-agent-cockpit" -CaseSensitive:$false` +Expected: No errors containing these files + +- [x] **Step 5: Commit** + +```bash +git add web/frontend/src/components/agent/cockpit/cockpit-page.tsx web/frontend/src/components/agent/cockpit/use-agent-cockpit.ts +git commit -m "feat(frontend): integrate Skills tab into cockpit" +``` + +--- + +## Plan Self-Review + +**1. Spec coverage:** +- [x] Skills API client functions → Task 1 +- [x] Cockpit skills hook → Task 2 +- [x] SkillsPage component with search/delete → Task 3, 4 +- [x] Skills tab in cockpit → Task 5 +- [x] Loading/error states → Task 4 + +**2. Placeholder scan:** +- [x] No "TODO", "TBD", or vague steps found +- [x] All code blocks contain actual implementation + +**3. Type consistency:** +- [x] `SkillSupportItem` type defined in Task 1, used consistently in Tasks 2-5 +- [x] `useCockpitSkills` hook return type matches usage in Tasks 4-5 + +**4. No hidden dependencies:** +- [x] Task 1 (API client) before Task 2 (hook) before Tasks 3-5 (components) +- [x] Each task produces independently testable changes + +--- + +Plan complete and saved to `docs/superpowers-optimized/plans/2026-05-07-skills-management.md`. + +**Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — Execute tasks in this session using executing-plans, with checkpoints + +**Which approach?** + diff --git a/docs/superpowers-optimized/specs/2026-05-07-integration-fix-design.md b/docs/superpowers-optimized/specs/2026-05-07-integration-fix-design.md new file mode 100644 index 000000000..97ffab56c --- /dev/null +++ b/docs/superpowers-optimized/specs/2026-05-07-integration-fix-design.md @@ -0,0 +1,223 @@ +# PicoClaw Integration Fix Design + +Date: 2026-05-07 + +## Scope + +Fix 4 integration gaps identified in the CLI/BE/FE audit: + +1. **Agent delete response mismatch** — frontend expects `{message}` but backend sends `{status}` +2. **Refactor agent manager** — eliminate code duplication between `pkg/agent/manager` and `web/backend/api/agents.go` +3. **Add Cron API** — expose CRUD for cron jobs via backend REST API +4. **Add MCP API** — expose CRUD for MCP servers via backend REST API +5. **Wire tool registry to API** — make `GET /api/tools` query `pkg/tools/registry.go` instead of static catalog +6. **Add MCP + Cron UI pages** — create frontend pages for MCP and Cron management + +## Non-Goals + +- Health UI (deferred) +- Web search config UI (already exists per subagent findings — web search tab exists) +- Agent CRUD CLI (CLI only provides direct chat, not CRUD — acceptable) +- Skills system redesign (already well-integrated) + +--- + +## 1. Agent Delete Response Mismatch + +### Problem +`web/frontend/src/api/agents.ts:70` expects `Promise<{ message: string }>` but `web/backend/api/agents.go:460` sends `{ status: "ok" }`. + +### Fix +Update the frontend to match the backend response: + +```typescript +// web/frontend/src/api/agents.ts:70 +export async function deleteAgent(slug: string): Promise<{ status: string }> { + // ... no other changes needed +} +``` + +The frontend `agents-page.tsx` uses `deleteAgent()` but only logs success via toast — it doesn't read the response body. So this is a low-risk fix. + +--- + +## 2. Refactor Agent Manager + +### Problem +`pkg/agent/manager/manager.go` and `web/backend/api/agents.go` both implement identical agent CRUD logic with duplicated types, regex, and file I/O. They diverge over time and cause maintenance burden. + +### Approach: Use pkg/agent/manager from API layer + +The backend API handler (`web/backend/api/agents.go`) should delegate to `manager.NewManager()`. Both use the same workspace path `~/.picoclaw/workspace/agents`. + +### Changes + +1. In `web/backend/api/agents.go`: + - Remove the duplicated `agentManager` struct, `expandAgentPath`, `slugRegex`, and all CRUD methods (List, Get, Create, Update, Delete, Import) + - Instead, import `github.com/sipeed/picoclaw/pkg/agent/manager` and use `manager.NewManager("")` + - Keep the HTTP handler wrappers and request/response types + - `agentManager` becomes a thin wrapper: `&manager.Manager{workspacePath: mgr.workspacePath}` — or just use `manager.NewManager` directly + +2. The `agent` struct type in `agents.go` and `pkg/agent/manager/types.go` need to be compared — one may have more fields. Check if they can be unified. + +3. Remove `agentCreateRequest`, `agentUpdateRequest`, `agentImportRequest` if `manager.AgentCreateRequest`/`AgentUpdateRequest` exist and are compatible. If `pkg/agent/manager/types.go` uses different struct names, either alias or adapt. + +**Decision**: Check `pkg/agent/manager/types.go` to see the actual request/response types before deciding on aliasing vs adaptation. + +### Fallback if pkg/agent/manager lacks types +If `pkg/agent/manager/types.go` doesn't export request/response types, keep the HTTP-layer structs in `agents.go` but have the handler methods delegate to `manager.Manager` for actual file I/O. This avoids breaking the API contract while eliminating duplication. + +--- + +## 3. Add Cron API + +### CLI Reference +`cmd/picoclaw/internal/cron/` has: list, add, remove, enable, disable. Jobs stored at `workspace/cron/jobs.json`. + +### New Backend Endpoints (web/backend/api/cron.go) + +| Method | Path | Handler | Purpose | +|--------|------|---------|---------| +| GET | `/api/cron/jobs` | handleListCronJobs | List all jobs | +| POST | `/api/cron/jobs` | handleAddCronJob | Add a new job | +| DELETE | `/api/cron/jobs/{id}` | handleDeleteCronJob | Delete a job | +| POST | `/api/cron/jobs/{id}/enable` | handleEnableCronJob | Enable a job | +| POST | `/api/cron/jobs/{id}/disable` | handleDisableCronJob | Disable a job | + +### Implementation +- `pkg/cron/service.go` has `NewCronService(path, nil)` and methods: `ListJobs()`, `AddJob()`, `DeleteJob()`, `EnableJob()`, `DisableJob()` +- Cron service reads/writes `workspace/cron/jobs.json` +- Workspace path comes from config: `cfg.WorkspacePath()` → append `/cron/jobs.json` +- Register routes in `router.go` → `registerCronRoutes(mux)` + +### Request/Response Shapes + +```json +// GET /api/cron/jobs +{ "jobs": [{ "id": "uuid", "name": "string", "schedule": { "kind": "every"|"cron", "every_ms": 3600000, "expr": "0 9 * * *" }, "message": "string", "channel": "", "to": "", "enabled": true, "last_run": 1234567890, "next_run": 1234567890 }] } + +// POST /api/cron/jobs +{ "name": "string", "every": 0, "cron": "0 9 * * *", "message": "string", "channel": "", "to": "" } +// Response: { "job": {...} } + +// DELETE /api/cron/jobs/{id} → { "status": "ok" } +// POST /api/cron/jobs/{id}/enable → { "status": "ok" } +// POST /api/cron/jobs/{id}/disable → { "status": "ok" } +``` + +--- + +## 4. Add MCP API + +### CLI Reference +`cmd/picoclaw/internal/mcp/` has: add, remove, list, edit, test, show. MCP servers stored in `config.json` under `tools.mcp.servers`. + +### New Backend Endpoints (web/backend/api/mcp.go) + +| Method | Path | Handler | Purpose | +|--------|------|---------|---------| +| GET | `/api/mcp/servers` | handleListMCPServers | List all MCP servers | +| GET | `/api/mcp/servers/{name}` | handleGetMCPServer | Get server config + test | +| POST | `/api/mcp/servers` | handleAddMCPServer | Add new MCP server | +| PUT | `/api/mcp/servers/{name}` | handleUpdateMCPServer | Update server | +| DELETE | `/api/mcp/servers/{name}` | handleDeleteMCPServer | Remove server | +| POST | `/api/mcp/servers/{name}/test` | handleTestMCPServer | Probe server health | + +### Implementation +- Read/write `config.json` via `pkg/config` +- MCP server config structure: `name`, `command`, `args[]`, `env{}`, `enabled` +- Workspace path from config for MCP server working dir +- For `test`: use same probe logic from `cmd/picoclaw/internal/mcp/probe.go` (import if available, or replicate inline — check if probe.go exists) + +### Request/Response Shapes + +```json +// GET /api/mcp/servers +{ "servers": [{ "name": "string", "command": "string", "args": [], "env": {}, "enabled": true, "status": "ok|error|disabled" }] } + +// POST /api/mcp/servers +{ "name": "string", "command": "string", "args": [], "env": {}, "enabled": true } + +// PUT /api/mcp/servers/{name} +// DELETE /api/mcp/servers/{name} → { "status": "ok" } +// POST /api/mcp/servers/{name}/test → { "status": "ok", "tool_count": 12 } +``` + +--- + +## 5. Wire Tool Registry to API + +### Problem +`web/backend/api/tools.go` has a hardcoded static `toolCatalog` (lines 75-202) instead of querying `pkg/tools/registry.go` at runtime. + +### Fix +Replace the static catalog with a dynamic query: + +```go +// In handleListTools, replace static toolCatalog with registry.GetAll() +registry := h.getToolRegistry() // inject or use global singleton +tools := registry.GetAll() +// Map to API response format +``` + +The challenge: the API response format may differ from `registry.GetAll()` output. Need to map: +- `registry.Tool` has `Name()`, `Description()`, `Parameters()`, `Execute()` +- API response needs `name`, `description`, `category`, `parameters` (JSON schema) + +### Approach +Create an adapter function that maps `Tool` interface to `ToolSupportItem` API response struct. Run it at request time so the API always reflects live registry. + +### Fallback +If the registry doesn't expose enough metadata (e.g., category), enrich the registry with a `Category()` method on the Tool interface. Or use a static map for category lookup if adding method to interface is too intrusive. + +--- + +## 6. Add MCP + Cron UI Pages + +### Frontend Routes +- `/agent/mcp` — MCP server management page +- `/agent/cron` — Cron job management page + +### Route Registration +Add to `web/frontend/src/routes/agent.tsx` (or wherever nested agent routes are defined): + +```tsx +// agent/mcp route +// agent/cron route +``` + +### Components to Create +- `web/frontend/src/components/agent/mcp/mcp-page.tsx` — MCP server list with add/edit/delete +- `web/frontend/src/components/agent/mcp/mcp-form-sheet.tsx` — Add/edit MCP server form +- `web/frontend/src/components/agent/cron/cron-page.tsx` — Cron job list with add/delete/enable/disable +- `web/frontend/src/components/agent/cron/cron-form-dialog.tsx` — Add cron job form + +### API Clients to Add +- `web/frontend/src/api/cron.ts` — CRUD hooks for cron API +- `web/frontend/src/api/mcp.ts` — CRUD hooks for MCP API + +### Design +Follow existing patterns (skills-page.tsx, models-page.tsx). Use sheets/dialogs for forms, list view for main display. + +--- + +## Implementation Order + +1. Fix agent delete response mismatch (frontend) — trivial, no risk +2. Add Cron backend API — self-contained, no dependencies +3. Add MCP backend API — self-contained, no dependencies +4. Wire tool registry to API — moderate, touches core pkg +5. Refactor agent manager (use pkg/agent/manager from API) — highest risk, do last +6. Add MCP UI — depends on MCP backend API +7. Add Cron UI — depends on Cron backend API + +Steps 2, 3, 6, 7 are independent and can run in parallel via subagents. + +--- + +## Failure Modes + +1. **Agent manager refactor breaks API**: If `pkg/agent/manager` types don't match API contract, fallback to keeping handler methods but calling into manager for file I/O only. +2. **Tool registry mapping loses fields**: If `Tool` interface lacks category, add a static category map in `api/tools.go` keyed by tool name, maintained manually until registry is enriched. +3. **Cron/MCP UI diverges from design system**: Follow existing page patterns (skills-page.tsx, models-page.tsx) exactly — same component library, same layout patterns. +4. **MCP server probe fails in API**: The CLI probe uses context with timeout. API handler should also use context with timeout. Fail gracefully with status="error" rather than crashing. \ No newline at end of file diff --git a/docs/superpowers-optimized/specs/2026-05-07-skills-management-design.md b/docs/superpowers-optimized/specs/2026-05-07-skills-management-design.md new file mode 100644 index 000000000..65490ecde --- /dev/null +++ b/docs/superpowers-optimized/specs/2026-05-07-skills-management-design.md @@ -0,0 +1,51 @@ +# Skills Management Design + +## Scope +Add skills management UI to the cockpit to view/install/delete skills (frontend only, backend API already exists). + +## Non-Goals +- Subagents tab (separate feature) +- Skill editing (backend doesn't support it, only install/delete/import) + +## Architecture +1. **Frontend API Client** (`web/frontend/src/api/skills.ts`): + - `listSkills()`: GET /api/skills → returns `SkillSupportItem[]` + - `getSkill(name)`: GET /api/skills/{name} → returns `SkillDetailResponse` + - `searchSkills(query, limit?, offset?)`: GET /api/skills/search?q=... → returns `SkillSearchResponse` + - `installSkill(slug, registry?)`: POST /api/skills/install → returns `InstallSkillResponse` + - `deleteSkill(name)`: DELETE /api/skills/{name} → returns `{status: "ok"}` + - `importSkill(file)`: POST /api/skills/import (multipart form) → returns `SkillSupportItem` + +2. **Cockpit Skills Hook** (`web/frontend/src/hooks/use-cockpit-skills.ts`): + - Uses TanStack Query to manage skills state (consistent with agents) + - Exposes: `skillsQuery`, `searchQuery`, `installSkill`, `deleteSkill` + - Handles loading/error states + +3. **Skills UI Integration**: + - Add "Skills" tab to `cockpit-page.tsx` (between "Tools" and "Agents") + - Create `SkillsPage` component (similar to `AgentsPage`): + - Search bar + - Grid of `SkillCard` components (name, description, origin kind, version) + - Install/Delete actions + - Reuse existing UI components: `Card`, `Button`, `Input`, `Badge` + +## Data Flow +Frontend → `launcherFetch` → Backend `/api/skills` → `pkg/skills` loader → returns installed skills + +## Error Handling +- Query errors: Show error message (similar to agents fix) +- Install failures: Toast error with message +- Delete confirm dialog (similar to agents) + +## Testing +- Verify API client functions match backend API types +- Test skills query loading/error states +- Test install/delete flows + +## Failure-Mode Check +1. **Backend skills tool disabled**: `ensureSkillRegistryToolEnabled` returns error → Frontend shows error, disable install actions. *Severity: Minor, document as known limitation.* +2. **Large skill list**: Pagination not implemented in backend list → Load all skills at once. *Severity: Minor, acceptable for now.* +3. **Install conflicts**: Skill already exists → Backend returns 409, frontend shows error. *Severity: Minor, handled by error message.* + +## Next Steps +After user approval, invoke `writing-plans` to decompose into implementation tasks. diff --git a/go.mod b/go.mod index f49cfd320..a33f6c83b 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 + github.com/gin-gonic/gin v1.12.0 github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -76,28 +77,44 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mau.fi/libsignal v0.2.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect diff --git a/go.sum b/go.sum index 083f59d1b..c68d659e1 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,12 @@ github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4p github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0= github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -110,6 +116,14 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -117,6 +131,10 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -142,6 +160,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -162,6 +181,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -179,6 +200,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0= github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -195,6 +218,11 @@ github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDw github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow= @@ -213,6 +241,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -224,6 +254,10 @@ github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdj github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -253,6 +287,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -277,6 +312,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= @@ -306,6 +343,8 @@ go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= diff --git a/pkg/agent/manager/manager.go b/pkg/agent/manager/manager.go new file mode 100644 index 000000000..916c13c93 --- /dev/null +++ b/pkg/agent/manager/manager.go @@ -0,0 +1,288 @@ +package manager + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const DefaultWorkspacePath = "~/.picoclaw/workspace/agents" + +var slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`) + +type Manager struct { + workspacePath string +} + +func NewManager(workspacePath string) *Manager { + if workspacePath == "" { + workspacePath = DefaultWorkspacePath + } + return &Manager{workspacePath: expandPath(workspacePath)} +} + +func expandPath(path string) string { + if strings.HasPrefix(path, "~/") { + home, _ := os.UserHomeDir() + return filepath.Join(home, path[2:]) + } + return path +} + +func (m *Manager) ensureDir() error { + if err := os.MkdirAll(m.workspacePath, 0755); err != nil { + return fmt.Errorf("failed to create agents directory: %w", err) + } + return nil +} + +func (m *Manager) ListAgents() ([]*Agent, error) { + if err := m.ensureDir(); err != nil { + return nil, err + } + + files, err := os.ReadDir(m.workspacePath) + if err != nil { + return nil, err + } + + var agents []*Agent + for _, file := range files { + if file.IsDir() || !strings.HasSuffix(file.Name(), ".md") { + continue + } + agent, err := m.readAgentFile(file.Name()) + if err != nil { + continue + } + agents = append(agents, agent) + } + return agents, nil +} + +func (m *Manager) GetAgent(slug string) (*Agent, error) { + if err := m.ensureDir(); err != nil { + return nil, err + } + + filename := slug + ".md" + fp := filepath.Join(m.workspacePath, filename) + if _, err := os.Stat(fp); os.IsNotExist(err) { + return nil, fmt.Errorf("agent not found: %s", slug) + } + return m.readAgentFile(filename) +} + +func (m *Manager) CreateAgent(req AgentCreateRequest) (*Agent, error) { + if err := m.ensureDir(); err != nil { + return nil, err + } + + slug := m.slugify(req.Name) + fp := filepath.Join(m.workspacePath, slug+".md") + + if _, err := os.Stat(fp); err == nil { + return nil, fmt.Errorf("agent already exists: %s", slug) + } + + agent := &Agent{ + Slug: slug, + Name: req.Name, + Description: req.Description, + SystemPrompt: req.SystemPrompt, + Model: req.Model, + ToolPermissions: req.ToolPermissions, + Status: AgentStatusEnabled, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := m.writeAgentFile(agent); err != nil { + return nil, err + } + return agent, nil +} + +func (m *Manager) UpdateAgent(slug string, req AgentUpdateRequest) (*Agent, error) { + if err := m.ensureDir(); err != nil { + return nil, err + } + + fp := filepath.Join(m.workspacePath, slug+".md") + if _, err := os.Stat(fp); os.IsNotExist(err) { + return nil, fmt.Errorf("agent not found: %s", slug) + } + + agent, err := m.readAgentFile(slug + ".md") + if err != nil { + return nil, err + } + + if req.Name != "" { + agent.Name = req.Name + agent.Slug = m.slugify(req.Name) + fp = filepath.Join(m.workspacePath, agent.Slug+".md") + } + if req.Description != "" { + agent.Description = req.Description + } + if req.SystemPrompt != "" { + agent.SystemPrompt = req.SystemPrompt + } + if req.Model != "" { + agent.Model = req.Model + } + if req.ToolPermissions != nil { + agent.ToolPermissions = req.ToolPermissions + } + if req.Status != "" { + agent.Status = AgentStatus(req.Status) + } + agent.UpdatedAt = time.Now() + + if err := m.writeAgentFile(agent); err != nil { + return nil, err + } + return agent, nil +} + +func (m *Manager) DeleteAgent(slug string) error { + if err := m.ensureDir(); err != nil { + return err + } + + fp := filepath.Join(m.workspacePath, slug+".md") + if err := os.Remove(fp); err != nil { + return fmt.Errorf("failed to delete agent: %w", err) + } + return nil +} + +func (m *Manager) ImportAgent(content string) (*Agent, error) { + if err := m.ensureDir(); err != nil { + return nil, err + } + + agent, err := m.parseAgentFromContent(content) + if err != nil { + return nil, err + } + + fp := filepath.Join(m.workspacePath, agent.Slug+".md") + if _, err := os.Stat(fp); err == nil { + return nil, fmt.Errorf("agent already exists: %s", agent.Slug) + } + + if err := m.writeAgentFile(agent); err != nil { + return nil, err + } + return agent, nil +} + +func (m *Manager) slugify(name string) string { + slug := strings.ToLower(strings.ReplaceAll(name, " ", "-")) + return slugRegex.FindString(slug) +} + +func (m *Manager) parseAgentFromContent(content string) (*Agent, error) { + var agent Agent + + lines := strings.Split(content, "\n") + if len(lines) < 3 || lines[0] != "---" { + agent.Name = "unknown" + agent.Slug = m.slugify("unknown-" + fmt.Sprintf("%d", time.Now().Unix())) + agent.SystemPrompt = content + return &agent, nil + } + + inFrontmatter := true + var frontmatter strings.Builder + contentStart := 0 + + for i, line := range lines { + if inFrontmatter && line == "---" { + if contentStart == 0 { + contentStart = i + 1 + inFrontmatter = false + } else { + break + } + } else if inFrontmatter { + frontmatter.WriteString(line + "\n") + } + } + + yamlLines := strings.Split(frontmatter.String(), "\n") + for _, line := range yamlLines { + if strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "name": + agent.Name = strings.Trim(value, "\"'") + case "description": + agent.Description = value + case "system_prompt": + agent.SystemPrompt = value + case "model": + agent.Model = strings.Trim(value, "\"'") + case "tool_permissions": + // Parse array + } + } + } + } + + if agent.Slug == "" { + agent.Slug = m.slugify(agent.Name) + } + if agent.Model == "" { + agent.Model = "claude-3-5-sonnet" + } + + return &agent, nil +} + +func (m *Manager) readAgentFile(filename string) (*Agent, error) { + fp := filepath.Join(m.workspacePath, filename) + data, err := os.ReadFile(fp) + if err != nil { + return nil, err + } + agent, err := m.parseAgentFromContent(string(data)) + if err != nil { + return nil, err + } + return agent, nil +} + +func (m *Manager) writeAgentFile(agent *Agent) error { + var content strings.Builder + + content.WriteString("---\n") + content.WriteString(fmt.Sprintf("name: %s\n", agent.Name)) + if agent.Description != "" { + content.WriteString(fmt.Sprintf("description: >\n %s\n", agent.Description)) + } + if agent.SystemPrompt != "" { + content.WriteString(fmt.Sprintf("system_prompt: >\n %s\n", agent.SystemPrompt)) + } + content.WriteString(fmt.Sprintf("model: %s\n", agent.Model)) + content.WriteString(fmt.Sprintf("slug: %s\n", agent.Slug)) + content.WriteString("---\n\n") + + content.WriteString(agent.SystemPrompt) + content.WriteString("\n") + + fp := filepath.Join(m.workspacePath, agent.Slug+".md") + if err := os.WriteFile(fp, []byte(content.String()), 0644); err != nil { + return err + } + return nil +} diff --git a/pkg/agent/manager/types.go b/pkg/agent/manager/types.go new file mode 100644 index 000000000..068eca8c3 --- /dev/null +++ b/pkg/agent/manager/types.go @@ -0,0 +1,48 @@ +package manager + +import "time" + +type AgentStatus string + +const ( + AgentStatusEnabled AgentStatus = "enabled" + AgentStatusDisabled AgentStatus = "disabled" +) + +type Agent struct { + Slug string `json:"slug" yaml:"slug"` + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + SystemPrompt string `json:"system_prompt" yaml:"system_prompt"` + Model string `json:"model" yaml:"model"` + ToolPermissions []string `json:"tool_permissions" yaml:"tool_permissions"` + Status AgentStatus `json:"status" yaml:"status"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` +} + +type AgentFile struct { + Slug string `json:"slug"` + Content string `json:"content"` +} + +type AgentListResponse struct { + Agents []*Agent `json:"agents"` +} + +type AgentCreateRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + SystemPrompt string `json:"system_prompt" binding:"required"` + Model string `json:"model" binding:"required"` + ToolPermissions []string `json:"tool_permissions"` +} + +type AgentUpdateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + SystemPrompt string `json:"system_prompt"` + Model string `json:"model"` + ToolPermissions []string `json:"tool_permissions"` + Status string `json:"status"` +} diff --git a/pkg/gateway/agent_api.go b/pkg/gateway/agent_api.go new file mode 100644 index 000000000..9e2cea4c2 --- /dev/null +++ b/pkg/gateway/agent_api.go @@ -0,0 +1,169 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "os" + + "github.com/sipeed/picoclaw/pkg/agent/manager" + "github.com/sipeed/picoclaw/pkg/health" +) + +var agentManager *manager.Manager + +func init() { + agentManager = manager.NewManager(getWorkspacePath() + "/agents") +} + +func getWorkspacePath() string { + if w := os.Getenv("PICOCLAW_WORKSPACE"); w != "" { + return w + } + home, _ := os.UserHomeDir() + return home + "/.picoclaw/workspace" +} + +// Agent API Handlers + +func handleAgentsList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + agents, err := agentManager.ListAgents() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(manager.AgentListResponse{Agents: agents}) +} + +func handleAgentGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + slug := r.URL.Query().Get("slug") + if slug == "" { + http.Error(w, "slug parameter required", http.StatusBadRequest) + return + } + + agent, err := agentManager.GetAgent(slug) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(agent) +} + +func handleAgentCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req manager.AgentCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + agent, err := agentManager.CreateAgent(req) + if err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(agent) +} + +func handleAgentUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + slug := r.URL.Query().Get("slug") + if slug == "" { + http.Error(w, "slug parameter required", http.StatusBadRequest) + return + } + + var req manager.AgentUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + agent, err := agentManager.UpdateAgent(slug, req) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(agent) +} + +func handleAgentDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + slug := r.URL.Query().Get("slug") + if slug == "" { + http.Error(w, "slug parameter required", http.StatusBadRequest) + return + } + + if err := agentManager.DeleteAgent(slug); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Agent deleted"}) +} + +func handleAgentImport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + agent, err := agentManager.ImportAgent(req.Content) + if err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(agent) +} + +// RegisterAgentAPI registers the agent API routes with the health server. +func RegisterAgentAPI(s *health.Server) { + s.HandleFunc("/api/agents", handleAgentsList) + s.HandleFunc("/api/agent", handleAgentGet) + s.HandleFunc("/api/agent/create", handleAgentCreate) + s.HandleFunc("/api/agent/update", handleAgentUpdate) + s.HandleFunc("/api/agent/delete", handleAgentDelete) + s.HandleFunc("/api/agent/import", handleAgentImport) +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 44973be6c..9c9153091 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -254,6 +254,9 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr }, nil }) + // Register agent API routes + RegisterAgentAPI(runningServices.HealthServer) + for _, bindHost := range listenResult.BindHosts { fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port))) } diff --git a/pkg/health/server.go b/pkg/health/server.go index 580a7749f..008ba4eb4 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -15,6 +15,7 @@ import ( type Server struct { server *http.Server + mux *http.ServeMux mu sync.RWMutex ready bool checks map[string]Check @@ -46,6 +47,7 @@ func NewServer(host string, port int, token string) *Server { checks: make(map[string]Check), startTime: time.Now(), authToken: token, + mux: mux, } mux.HandleFunc("/health", s.healthHandler) @@ -57,7 +59,7 @@ func NewServer(host string, port int, token string) *Server { addr := net.JoinHostPort(host, strconv.Itoa(port)) s.server = &http.Server{ Addr: addr, - Handler: mux, + Handler: s.mux, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } @@ -136,6 +138,11 @@ func (s *Server) SetSubagentStatusFunc(fn func(channel, chatID string) (any, err s.subagentStatusFunc = fn } +// HandleFunc registers a new handler for the given pattern. +func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { + s.mux.HandleFunc(pattern, handler) +} + // permissionGrantHandler handles POST /internal/permission/grant requests. func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/pkg/tools/toolskill.go b/pkg/tools/toolskill.go index 915d64679..a4a2e0a88 100644 --- a/pkg/tools/toolskill.go +++ b/pkg/tools/toolskill.go @@ -3,8 +3,7 @@ package tools import ( "io/ioutil" - "path/filepath" - "strings" + "regexp" "gopkg.in/yaml.v3" ) @@ -43,7 +42,7 @@ func LoadToolSkill(filePath string) (*ToolSkill, error) { // extractTagsFromContent extracts tags from markdown content func extractTagsFromContent(content string) []string { // Look for #tag patterns - re := strings.NewReplacer(`#([a-zA-Z0-9_-]+)`, `$1`) + re := regexp.MustCompile(`#([a-zA-Z0-9_-]+)`) matches := re.FindAllStringSubmatch(content, -1) tags := make([]string, 0, len(matches)) for _, m := range matches { diff --git a/project-map.md b/project-map.md index c7b836213..013151717 100644 --- a/project-map.md +++ b/project-map.md @@ -1,5 +1,5 @@ # Project Map -_Generated: 2026-05-05 | Git: 07107384_ +_Generated: 2026-05-07 | Git: $(git rev-parse HEAD 2>/dev/null || echo "local") ## Directory Structure cmd/ — CLI entry points (picoclaw main, membench, internal subcommands) @@ -10,6 +10,7 @@ workspace/ — Runtime workspace (skills, memory) docs/ — Documentation (architecture, channels, guides, migration, reference) docs/reference/tools-api.md — Complete tools API documentation: available tools, data structures, backend API endpoints, MCP integration docs/reference/exec-tool.md — Exec tool deep dive: how it works, security measures, how to disable/sandbox/remove completely +docs/superpowers-optimized/ — Superpowers workflow specs and plans config/ — Configuration templates and examples build/ — Build scripts and artifacts docker/ — Docker containerization files @@ -20,74 +21,80 @@ assets/ — Static assets (logo, images) ## Key Files cmd/picoclaw/main.go — Main CLI entry point using Cobra; registers subcommands (agent, auth, gateway, mcp, migrate, model, skills, etc.) cmd/picoclaw/internal/ — Internal CLI command implementations (agent, auth, gateway, mcp, migrate, model, skills, status, version, onboard, cron, cliui) -pkg/agent/ — Core agent logic: context management, pipelines (setup/llm/finalize), turn coordination, event handling, hooks, steering, thinking, prompt contributors + +### Core Packages +pkg/agent/ — Core agent logic: context management, pipelines, turn coordination, event handling, hooks, steering, thinking +pkg/agent/manager/ — Agent lifecycle management (manager.go, types.go) pkg/agent/context_manager.go — Manages LLM context lifecycle, caching, and budget enforcement pkg/agent/pipeline.go — Orchestrates agent execution phases (setup → LLM → tools → finalize) pkg/channels/ — Multi-platform chat integrations: Discord, Telegram, Slack, WeChat, WeCom, Feishu, DingTalk, IRC, LINE, Matrix, VK, WhatsApp, OneBot, MaixCam, Pico -pkg/providers/ — AI model provider integrations: Anthropic, OpenAI-compatible, Azure, AWS Bedrock, CLI, HTTP API; shared protocol types and OAuth +pkg/providers/ — AI model provider integrations: Anthropic, OpenAI-compatible, Azure, AWS Bedrock, CLI, HTTP API pkg/config/ — Configuration loading, validation, and environment variable handling pkg/skills/ — Skills system for extending agent capabilities pkg/tools/ — Built-in tools: filesystem (fs), hardware interaction, shared utilities, integration tools pkg/mcp/ — Model Context Protocol (MCP) server implementation for tool/resource exposure pkg/memory/ — Agent memory management (short-term/long-term, persistence) -pkg/gateway/ — Gateway for routing messages between channels and agents +pkg/gateway/ — Gateway for routing messages between channels and agents (gateway.go, agent_api.go) pkg/auth/ — Authentication and credential management (OAuth, API keys, encryption) -pkg/identity/ — Identity and user/session management -pkg/session/ — Session state management across channels -pkg/state/ — Application state persistence -pkg/credential/ — Secure credential storage (ChaCha20-Poly1305 encryption) -pkg/routing/ — Message routing logic between channels, agents, and models +pkg/health/ — Health check endpoints and diagnostics server.go pkg/bus/ — Internal event bus for decoupled communication -pkg/events/ — Event definitions and handling (device events, system events) -pkg/cron/ — Cron-based scheduling for periodic tasks -pkg/logger/ — Logging infrastructure -pkg/health/ — Health check endpoints and diagnostics -pkg/heartbeat/ — Heartbeat/keepalive mechanism for long-running processes -pkg/updater/ — Self-update functionality (minio/selfupdate) -pkg/migrate/ — Database and config migration utilities -pkg/media/ — Media processing (images, audio) -pkg/audio/asr/ — Automatic Speech Recognition (ASR) providers -pkg/audio/tts/ — Text-to-Speech (TTS) providers -pkg/tokenizer/ — Token counting and management for LLM context budgets -pkg/netbind/ — Network binding utilities for embedded/specific network configs -pkg/fileutil/ — File utility functions -pkg/devices/ — Device management (events, sources) for hardware integrations -pkg/isolation/ — Sandboxing and isolation for security -pkg/seahorse/ — Seahorse integration (encrypted storage) -pkg/constants/ — Package-level constants -web/backend/api/ — Backend API route definitions -web/backend/middleware/ — HTTP middleware (auth, CORS, logging) -web/backend/dashboardauth/ — Dashboard authentication logic -web/backend/model/ — Backend data models -web/backend/launcherconfig/ — Launcher configuration -web/frontend/src/ — Frontend source (components, routes, store, features, hooks, lib, api, i18n) -go.mod — Go 1.25.9 module definition; key deps: Cobra, DiscordGo, Telego, Anthropic SDK, AWS SDK v2, MCP SDK, gRPC, various channel SDKs -go.sum — Dependency checksums + +### Backend API (web/backend/api/) +- pico.go, router.go — Main API routing +- agents.go — Agent CRUD endpoints (/api/agents, /api/agent/*) +- skills.go — Skills management endpoints (/api/skills, /api/skills/*) + +### Frontend (web/frontend/src/) +api/ — REST client wrappers +- agents.ts — Agent API client (listAgents, getAgent, createAgent, updateAgent, deleteAgent) +- skills.ts — Skills API client (listSkills, getSkill, searchSkills, installSkill, deleteSkill) + +components/agent/ +├── agents/ — Agent management UI +│ ├── agents-page.tsx — Main agents list with search, create, edit, delete +│ ├── agent-card.tsx — Agent display card +│ └── agent-form-modal.tsx — Create/edit agent form +├── cockpit/ — Main cockpit dashboard +│ ├── cockpit-page.tsx — Tools, Skills, Agents tabs with memory graph +│ └── use-agent-cockpit.ts — Cockpit state hook (tools, skills, agents, subagents) +├── research/ — Agent research/analysis features +│ ├── research-page.tsx — Research dashboard +│ ├── research-config.tsx — Configuration panel +│ ├── research-agents.tsx — Agent selection +│ ├── research-reports.tsx — Reports view +│ └── research-graph.tsx — Graph visualization +├── skills/ — Skills management UI +│ ├── skills-page.tsx — Skills list with search, delete +│ ├── skill-card.tsx — Skill display card +│ └── index.ts — Barrel exports +└── hub/ — Skill marketplace + +hooks/ — React hooks +- use-agent-cockpit.ts — Main cockpit state +- use-cockpit-skills.ts — Skills state with TanStack Query +- use-agents.ts — Agent state management + +routes/agent/ — Route definitions +- skills.tsx → /agent/skills +- research.tsx → /agent/research + +## Cockpit UI Features +The cockpit provides tabs: +- **Tools**: Enable/disable tools with status badges +- **Agents**: CRUD for agent definitions (stored as .md in ~/.picoclaw/workspace/agents) +- **Skills**: View/delete installed skills +- **Sidebar**: Memory network graph, subagent manifest + +go.mod — Go 1.25.9 module definition Makefile — Build targets (build, test, lint, release) .goreleaser.yaml — GoReleaser config for cross-platform releases -.golangci.yaml — GolangCI-Lint configuration -README.md — Project overview: ultra-lightweight AI assistant for $10 hardware, <10MB RAM, inspired by NanoBot -ROADMAP.md — Vision: lightweight, secure, autonomous AI Agent; core optimization, security hardening, protocol-first architecture -CONTRIBUTING.md — Contribution guidelines -LICENSE — MIT License -.env.example — Example environment variables template -.dockerignore / .gitignore — Ignore rules for Docker and Git ## Critical Constraints -- Target: Runs on $10 hardware (e.g., RISC-V SBCs) with <10MB RAM, core process <20MB for 64MB boards -- Go 1.25.9 required (very recent version) -- Self-bootstrapped: AI Agent drove architecture migration and optimization (not a fork) -- Memory optimization takes precedence over storage size -- Security: Prompt injection defense, tool abuse prevention, SSRF protection, filesystem sandbox, context isolation, privacy redaction -- Crypto: Uses ChaCha20-Poly1305 for secret storage (upgrade from older algorithms) -- OAuth 2.0 Flow: Deprecating hardcoded API keys in CLI -- Architecture: Migrating from "Vendor-based" to "Protocol-based" classification (OpenAI-compatible, Ollama-compatible) -- Multi-architecture: x86_64, ARM64, MIPS, RISC-V, LoongArch -- Channel diversity: 14+ chat platforms supported with platform-specific adapters -- Provider diversity: Anthropic, OpenAI-compat, Azure, Bedrock, local (Ollama, vLLM, LM Studio, Mistral) +- Target: Ultra-lightweight for $10 hardware with <10MB RAM +- Go 1.25.9 required +- Security: Prompt injection defense, tool abuse prevention, SSRF protection +- Workspace: ~/.picoclaw/workspace/ (agents, skills, memory) - Frontend: TypeScript/React with TanStack router/query -- Build: Makefile + GoReleaser for cross-platform binaries -- Workspace: Skills and memory stored in workspace/ directory at runtime ## Hot Files -pkg/agent/agent.go, pkg/agent/pipeline.go, pkg/agent/context_manager.go, pkg/agent/definition.go, pkg/channels/ (multiple files), pkg/providers/ (multiple files), cmd/picoclaw/main.go, pkg/config/, web/backend/api/, web/frontend/src/ +pkg/agent/manager/, pkg/gateway/, web/backend/api/agents.go, web/frontend/src/api/agents.ts, web/frontend/src/components/agent/cockpit/, web/frontend/src/components/agent/agents/, web/frontend/src/components/agent/research/ \ No newline at end of file diff --git a/tmp.txt b/tmp.txt new file mode 100644 index 000000000..efda91d90 --- /dev/null +++ b/tmp.txt @@ -0,0 +1,5 @@ +-----BEGIN CERTIFICATE----- +LS0tCm5hbWU6IFNpbXBsZSBBZ2VudApkZXNjcmlwdGlvbjogQSBzaW1wbGUgdGVz +dCBhZ2VudApzeXN0ZW1fcHJvbXB0OiBZb3UgYXJlIGEgaGVscGZ1bCBhc3Npc3Rh +bnQuCm1vZGVsOiBxd2VuMy41OjRiCi0tLQ== +-----END CERTIFICATE----- diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 88dac045d..8e2a185cd 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -10,6 +10,7 @@ import ( "net/http/httputil" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -17,6 +18,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/memory" ppid "github.com/sipeed/picoclaw/pkg/pid" ) @@ -799,7 +801,10 @@ func appendVaultGraph( noteID := "vault:" + noteName // Parse frontmatter and content - fm, body := memory.ParseFrontmatter(string(data)) + fm, body, err := memory.ParseFrontmatter(string(data)) + if err != nil { + continue + } // Create node label := noteName diff --git a/web/backend/dist/index.html b/web/backend/dist/index.html index 15af737cc..e0d02a5d9 100644 --- a/web/backend/dist/index.html +++ b/web/backend/dist/index.html @@ -1,38 +1,20 @@ - - - - - - - - - - - PicoClaw - - - - - - - -
- - - - - - - - - - + + + + + + + + + + + PicoClaw + + + + + + +
+ + diff --git a/web/backend/dist/sessions.js b/web/backend/dist/sessions.js deleted file mode 100644 index 1e1967469..000000000 --- a/web/backend/dist/sessions.js +++ /dev/null @@ -1,32 +0,0 @@ -// web/backend/dist/sessions.js -// Session History Controller (~4KB) -function loadSessionHistory() { - const history = document.getElementById('session-history'); - if (!history) return; - - fetch('/api/sessions/search?limit=20') - .then(r => r.json()) - .then(sessions => { - history.innerHTML = '

Recent Sessions

'; - sessions.forEach(session => { - const div = document.createElement('div'); - div.className = 'session-item'; - div.innerHTML = ` -
${session.title || 'Untitled'}
-
${(session.tags || []).map(t => `#${t}`).join(' ')}
-
${new Date(session.timestamp).toLocaleDateString()}
- `; - div.onclick = () => loadSessionContext(session.id); - history.appendChild(div); - }); - }) - .catch(e => console.error('Failed to load sessions:', e)); -} - -function loadSessionContext(sessionId) { - // TODO: load session context into chat - console.log('Load session:', sessionId); -} - -// Expose -window.loadSessionHistory = loadSessionHistory; diff --git a/web/backend/dist/styles/vault.css b/web/backend/dist/styles/vault.css deleted file mode 100644 index eb1c44868..000000000 --- a/web/backend/dist/styles/vault.css +++ /dev/null @@ -1,82 +0,0 @@ -/* web/backend/dist/styles/vault.css */ -.sidebar { - position: fixed; - left: 0; - top: 0; - width: 300px; - height: 100%; - background: var(--background-primary, #1e1e1e); - color: var(--text-normal, #dcddde); - padding: 10px; - overflow-y: auto; - z-index: 1000; -} - -.sidebar.hidden { - display: none; -} - -.sidebar-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.sidebar-header h3 { - margin: 0; -} - -.tree-item { - padding: 5px 10px; - cursor: pointer; - border-radius: 4px; -} - -.tree-item:hover { - background: var(--background-secondary, #2d2d2d); -} - -.tag-badge { - display: inline-block; - background: var(--background-secondary, #2d2d2d); - border-radius: 12px; - padding: 2px 8px; - margin: 2px; - cursor: pointer; - font-size: 0.9em; -} - -.tag-badge:hover { - background: var(--interactive-accent, #5865f2); - color: white; -} - -.skill-item { - padding: 8px; - border-bottom: 1px solid var(--background-secondary, #2d2d2d); -} - -.skill-name { - font-weight: bold; -} - -.skill-usage { - font-size: 0.9em; - color: var(--text-muted, #72767d); -} - -.session-item { - padding: 8px; - border-bottom: 1px solid var(--background-secondary, #2d2d2d); - cursor: pointer; -} - -.session-title { - font-weight: bold; -} - -.session-tags { - font-size: 0.9em; - color: var(--text-muted, #72767d); -} diff --git a/web/backend/dist/tags.js b/web/backend/dist/tags.js deleted file mode 100644 index 91e53d3d3..000000000 --- a/web/backend/dist/tags.js +++ /dev/null @@ -1,36 +0,0 @@ -// web/backend/dist/tags.js -// Tag Cloud Controller (~3KB) -function loadTagCloud() { - const cloud = document.getElementById('tag-cloud'); - if (!cloud) return; - - fetch('/api/vault/tags') - .then(r => r.json()) - .then(tags => { - cloud.innerHTML = '

Tags

'; - tags.forEach(tag => { - const badge = document.createElement('span'); - badge.className = 'tag-badge'; - badge.textContent = `#${tag.name} (${tag.count})`; - badge.onclick = () => filterByTag(tag.name); - cloud.appendChild(badge); - }); - }) - .catch(e => console.error('Failed to load tags:', e)); -} - -function filterByTag(tagName) { - fetch(`/api/sessions/search?tag=${encodeURIComponent(tagName)}`) - .then(r => r.json()) - .then(data => displaySearchResults(data)) - .catch(e => console.error('Failed to filter by tag:', e)); -} - -function displaySearchResults(data) { - // TODO: render search results in UI - console.log('Search results:', data); -} - -// Expose -window.loadTagCloud = loadTagCloud; -window.filterByTag = filterByTag; diff --git a/web/backend/dist/tools-skills.js b/web/backend/dist/tools-skills.js deleted file mode 100644 index c12105c46..000000000 --- a/web/backend/dist/tools-skills.js +++ /dev/null @@ -1,42 +0,0 @@ -// web/backend/dist/tools-skills.js -// Tool Skills Panel Controller (~3KB) -function loadToolSkills() { - const panel = document.getElementById('tool-skills-panel'); - if (!panel) return; - - fetch('/api/tools/skills') - .then(r => r.json()) - .then(skills => { - panel.innerHTML = '

Tool Skills

'; - skills.forEach(skill => { - const div = document.createElement('div'); - div.className = 'skill-item'; - div.innerHTML = ` -
${skill.name || 'Unknown'}
-
Used ${skill.usage_count || 0} times
-
${(skill.tags || []).map(t => `#${t}`).join(' ')}
- `; - panel.appendChild(div); - }); - }) - .catch(e => console.error('Failed to load tool skills:', e)); -} - -async function searchCommunityRegistry(query) { - try { - const response = await fetch(`/api/tools/registry?q=${encodeURIComponent(query || '')}`); - const results = await response.json(); - displayRegistryResults(results); - } catch (e) { - console.error('Failed to search registry:', e); - } -} - -function displayRegistryResults(results) { - console.log('Registry results:', results); - // TODO: render results in UI -} - -// Expose -window.loadToolSkills = loadToolSkills; -window.searchCommunityRegistry = searchCommunityRegistry; diff --git a/web/backend/dist/vault.js b/web/backend/dist/vault.js deleted file mode 100644 index e22903ac3..000000000 --- a/web/backend/dist/vault.js +++ /dev/null @@ -1,64 +0,0 @@ -// web/backend/dist/vault.js -// Vault Sidebar Controller (Vanilla JS, ~5KB) -document.addEventListener('DOMContentLoaded', () => { - const sidebar = document.getElementById('vault-sidebar'); - const toggleBtn = document.getElementById('toggle-sidebar'); - const closeBtn = document.getElementById('close-sidebar'); - - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - sidebar.classList.toggle('hidden'); - if (!sidebar.classList.contains('hidden')) { - loadVaultTree(); - } - }); - } - - if (closeBtn) { - closeBtn.addEventListener('click', () => { - sidebar.classList.add('hidden'); - }); - } - - async function loadVaultTree() { - try { - const response = await fetch('/api/vault/list'); - const data = await response.json(); - renderTree(data.notes || []); - } catch (e) { - console.error('Failed to load vault tree:', e); - } - } - - function renderTree(items) { - const tree = document.getElementById('vault-tree'); - if (!tree) return; - tree.innerHTML = ''; - items.forEach(item => { - const div = document.createElement('div'); - div.className = 'tree-item'; - div.textContent = item.name || item.path || 'Untitled'; - div.onclick = () => loadNote(item.path || item.name); - tree.appendChild(div); - }); - } - - async function loadNote(path) { - try { - const response = await fetch(`/api/vault/note?path=${encodeURIComponent(path)}`); - const data = await response.json(); - showNoteModal(data); - } catch (e) { - console.error('Failed to load note:', e); - } - } - - function showNoteModal(noteData) { - // Simple modal implementation - alert(`Note: ${noteData.title || 'Note'}\n\n${noteData.content || ''}`); - } - - // Expose functions for other scripts - window.loadVaultTree = loadVaultTree; - window.loadNote = loadNote; -}); diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts index 958808afd..02de633a2 100644 --- a/web/frontend/src/api/skills.ts +++ b/web/frontend/src/api/skills.ts @@ -3,20 +3,24 @@ import { launcherFetch } from "@/api/http" export interface SkillSupportItem { name: string path: string - source: "workspace" | "global" | "builtin" | string + source: string description: string - origin_kind: "builtin" | "third_party" | "manual" | string + origin_kind: string registry_name?: string registry_url?: string installed_version?: string installed_at?: number } +export interface SkillsListResponse { + skills: SkillSupportItem[] +} + export interface SkillDetailResponse extends SkillSupportItem { content: string } -export interface SkillRegistrySearchResult { +export interface SkillSearchResultItem { score: number slug: string display_name: string @@ -28,25 +32,17 @@ export interface SkillRegistrySearchResult { installed_name?: string } -interface SkillsResponse { - skills: SkillSupportItem[] -} - export interface SkillSearchResponse { - results: SkillRegistrySearchResult[] + results: SkillSearchResultItem[] limit: number offset: number next_offset?: number has_more: boolean } -type SkillActionResponse = Partial & { - status?: string -} - export interface InstallSkillRequest { slug: string - registry: string + registry?: string version?: string force?: boolean } @@ -61,90 +57,77 @@ export interface InstallSkillResponse { skill?: SkillSupportItem } -async function request(path: string, options?: RequestInit): Promise { - const res = await launcherFetch(path, options) - if (!res.ok) { - throw new Error(await extractErrorMessage(res)) - } - return res.json() as Promise +export async function listSkills(): Promise { + const res = await launcherFetch("/api/skills") + if (!res.ok) throw new Error(`Failed to list skills: ${res.status}`) + return res.json() } -export async function getSkills(): Promise { - return request("/api/skills") +export async function getSkills(): Promise { + return listSkills() } export async function getSkill(name: string): Promise { - return request(`/api/skills/${encodeURIComponent(name)}`) + const res = await launcherFetch(`/api/skills/${encodeURIComponent(name)}`) + if (!res.ok) throw new Error(`Failed to get skill: ${res.status}`) + return res.json() } -export async function searchSkills( - query: string, - limit = 20, - offset = 0, -): Promise { - const params = new URLSearchParams({ - q: query, - limit: String(limit), - offset: String(offset), - }) - return request(`/api/skills/search?${params.toString()}`) +export interface SkillRegistrySearchResult { + score: number + slug: string + display_name: string + summary: string + version: string + registry_name: string + url?: string + installed: boolean + installed_name?: string } -export async function installSkill( - input: InstallSkillRequest, -): Promise { - return request("/api/skills/install", { +export async function searchSkills(query: string, limit = 20, offset = 0): Promise { + const params = new URLSearchParams({ q: query }) + if (limit !== 20) params.set("limit", limit.toString()) + if (offset !== 0) params.set("offset", offset.toString()) + const res = await launcherFetch(`/api/skills/search?${params.toString()}`) + if (!res.ok) throw new Error(`Failed to search skills: ${res.status}`) + return res.json() +} + +export async function installSkill(data: InstallSkillRequest): Promise { + const res = await launcherFetch("/api/skills/install", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), + body: JSON.stringify(data), }) + if (!res.ok) { + const error = await res.json().catch(() => ({ message: `Failed to install skill: ${res.status}` })) + throw new Error(error.message || `Failed to install skill: ${res.status}`) + } + return res.json() } -export async function importSkill(file: File): Promise { - const formData = new FormData() - formData.set("file", file) +export async function deleteSkill(name: string): Promise<{ status: string }> { + const res = await launcherFetch(`/api/skills/${encodeURIComponent(name)}`, { + method: "DELETE", + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ message: `Failed to delete skill: ${res.status}` })) + throw new Error(error.message || `Failed to delete skill: ${res.status}`) + } + return res.json() +} +export async function importSkill(file: File): Promise { + const formData = new FormData() + formData.append("file", file) const res = await launcherFetch("/api/skills/import", { method: "POST", body: formData, }) if (!res.ok) { - throw new Error(await extractErrorMessage(res)) + const error = await res.json().catch(() => ({ message: `Failed to import skill: ${res.status}` })) + throw new Error(error.message || `Failed to import skill: ${res.status}`) } - return res.json() as Promise -} - -export async function deleteSkill(name: string): Promise { - return request( - `/api/skills/${encodeURIComponent(name)}`, - { - method: "DELETE", - }, - ) -} - -async function extractErrorMessage(res: Response): Promise { - try { - const raw = await res.text() - if (raw.trim() === "") { - return `API error: ${res.status} ${res.statusText}` - } - try { - const body = JSON.parse(raw) as { - error?: string - errors?: string[] - } - if (Array.isArray(body.errors) && body.errors.length > 0) { - return body.errors.join("; ") - } - if (typeof body.error === "string" && body.error.trim() !== "") { - return body.error - } - } catch { - return raw.trim() - } - } catch { - // ignore invalid body - } - return `API error: ${res.status} ${res.statusText}` + return res.json() } diff --git a/web/frontend/src/components/agent/agents/agent-card.tsx b/web/frontend/src/components/agent/agents/agent-card.tsx new file mode 100644 index 000000000..e21dbefd6 --- /dev/null +++ b/web/frontend/src/components/agent/agents/agent-card.tsx @@ -0,0 +1,101 @@ +import { IconEdit, IconTrash, IconRobot, IconSettings } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { Agent } from "@/api/agents" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +interface AgentCardProps { + agent: Agent + onEdit: () => void + onDelete: () => void + onToggle: (enabled: boolean) => void +} + +export function AgentCard({ agent, onEdit, onDelete, onToggle }: AgentCardProps) { + const { t } = useTranslation() + + const statusColor = agent.status === "enabled" ? "bg-[#F27D26] text-black" : "bg-white/10 text-white/40" + + return ( + +
+ + +
+
+
+ + {agent.name} + + + {agent.status} + +
+ + {agent.description} + +
+
+ +
+
+
+ + +
+
+ + {agent.model} +
+ {agent.tool_permissions.length > 0 && ( +
+ + {agent.tool_permissions.slice(0, 3).join(", ")} + {agent.tool_permissions.length > 3 && ( + +{agent.tool_permissions.length - 3} + )} +
+ )} +
+ +
+ + +
+
+ + ) +} diff --git a/web/frontend/src/components/agent/agents/agent-form-modal.tsx b/web/frontend/src/components/agent/agents/agent-form-modal.tsx new file mode 100644 index 000000000..e33d51954 --- /dev/null +++ b/web/frontend/src/components/agent/agents/agent-form-modal.tsx @@ -0,0 +1,230 @@ +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + createAgent, + updateAgent, + type Agent, + type AgentCreateRequest, +} from "@/api/agents" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { useChatModels } from "@/hooks/use-chat-models" +import { useGateway } from "@/hooks/use-gateway" + +interface AgentFormModalProps { + isOpen: boolean + onClose: (open: boolean) => void + agent?: Agent | null + onSave?: () => void +} + +export function AgentFormModal({ + isOpen, + onClose, + agent, + onSave, +}: AgentFormModalProps) { + const { t } = useTranslation() + const { state: gatewayState } = useGateway() + const { localModels, oauthModels, defaultModelName } = useChatModels({ + isConnected: gatewayState === "running", + }) + + const isEdit = !!agent + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [systemPrompt, setSystemPrompt] = useState("") + const [model, setModel] = useState("") + const [toolPermissions, setToolPermissions] = useState("") + + // Reset form when modal opens with new agent data + useEffect(() => { + if (isOpen) { + if (agent) { + setName(agent.name) + setDescription(agent.description || "") + setSystemPrompt(agent.system_prompt || "") + setModel(agent.model || "") + setToolPermissions((agent.tool_permissions || []).join(", ")) + } else { + setName("") + setDescription("") + setSystemPrompt("") + setModel(defaultModelName || "") + setToolPermissions("") + } + } + }, [agent, defaultModelName, isOpen]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!name.trim()) { + toast.error("Name is required") + return + } + if (!systemPrompt.trim()) { + toast.error("System prompt is required") + return + } + if (!model.trim()) { + toast.error("Model is required") + return + } + + const request: AgentCreateRequest = { + name: name.trim(), + description: description.trim(), + system_prompt: systemPrompt.trim(), + model: model.trim(), + tool_permissions: toolPermissions + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + } + + try { + if (isEdit && agent) { + await updateAgent(agent.slug, request) + toast.success( + t("pages.agent.agents.update_success", "Agent updated successfully"), + ) + } else { + await createAgent(request) + toast.success( + t("pages.agent.agents.create_success", "Agent created successfully"), + ) + } + onSave?.() + onClose(false) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to save agent") + } + } + + const availableModels = [...localModels, ...oauthModels] + + return ( + + + + + {isEdit + ? t("pages.agent.agents.edit_agent", "Edit Agent") + : t("pages.agent.agents.create_agent", "Create Agent")} + + + +
+
+ + setName(e.target.value)} + placeholder="e.g., researcher, coder" + /> +
+ +
+ +