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
This commit is contained in:
parent
9d48e39d09
commit
9f011dbe67
45 changed files with 5443 additions and 678 deletions
|
|
@ -90,6 +90,13 @@
|
||||||
"api_key": "your-modelscope-access-token",
|
"api_key": "your-modelscope-access-token",
|
||||||
"api_base": "https://api-inference.modelscope.cn/v1"
|
"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_name": "azure-gpt5",
|
||||||
"model": "azure/my-gpt5-deployment",
|
"model": "azure/my-gpt5-deployment",
|
||||||
|
|
|
||||||
1
debug.txt
Normal file
1
debug.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
This is a test file
|
||||||
6
decoded.txt
Normal file
6
decoded.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: Simple Agent
|
||||||
|
description: A simple test agent
|
||||||
|
system_prompt: You are a helpful assistant.
|
||||||
|
model: qwen3.5:4b
|
||||||
|
---
|
||||||
|
|
@ -458,3 +458,57 @@ registry.RegisterHidden(tools.NewRegexSearchTool(registry, 5, 10))
|
||||||
// Promote hidden tools (make them available to LLM)
|
// Promote hidden tools (make them available to LLM)
|
||||||
registry.PromoteTools([]string{"tool_search_tool_regex"}, 10) // TTL=10 turns
|
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...
|
||||||
|
```
|
||||||
|
|
|
||||||
860
docs/superpowers-optimized/plans/2026-05-07-integration-fix.md
Normal file
860
docs/superpowers-optimized/plans/2026-05-07-integration-fix.md
Normal file
|
|
@ -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<CronJobResponse> {
|
||||||
|
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 <CronPage />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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<string, string>
|
||||||
|
enabled: boolean
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPServerResponse {
|
||||||
|
servers: MCPServer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMCPServers(): Promise<MCPServerResponse> {
|
||||||
|
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<string, string>
|
||||||
|
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<string, string>
|
||||||
|
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 <MCPPage />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 |
|
||||||
1016
docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md
Normal file
1016
docs/superpowers-optimized/plans/2026-05-07-research-cockpit.md
Normal file
File diff suppressed because it is too large
Load diff
673
docs/superpowers-optimized/plans/2026-05-07-skills-management.md
Normal file
673
docs/superpowers-optimized/plans/2026-05-07-skills-management.md
Normal file
|
|
@ -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<SkillsListResponse> {
|
||||||
|
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<SkillDetailResponse> {
|
||||||
|
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<SkillSearchResponse> {
|
||||||
|
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<InstallSkillResponse> {
|
||||||
|
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<SkillSupportItem> {
|
||||||
|
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 (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight">
|
||||||
|
{skill.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge className={cn("text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5", kindColor)}>
|
||||||
|
{originKindLabel(skill.origin_kind)}
|
||||||
|
</Badge>
|
||||||
|
{skill.installed_version && (
|
||||||
|
<Badge className="bg-white/10 text-white/60 text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5">
|
||||||
|
v{skill.installed_version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
|
||||||
|
{skill.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-xs text-white/60">
|
||||||
|
{skill.registry_name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<IconWorld className="size-3.5" />
|
||||||
|
{skill.registry_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{skill.source && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<IconFolder className="size-3.5" />
|
||||||
|
{skill.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{skill.origin_kind === "manual" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-white/60 hover:text-destructive hover:bg-destructive/10"
|
||||||
|
title={t("common.delete")}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [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<SkillSupportItem | null>(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 (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Loading skills...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<p className="text-destructive">Failed to load skills. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContent = (
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-md flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t("common.search", "Search skills...")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filteredSkills.length === 0 ? (
|
||||||
|
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{deferredSearchQuery
|
||||||
|
? t("pages.agent.skills.no_results", "No skills found")
|
||||||
|
: t("pages.agent.skills.no_skills", "No skills installed")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground/60">
|
||||||
|
{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")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSkills.map((skill) => (
|
||||||
|
<SkillCard
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
onDelete={() => setSkillToDelete(skill)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const modals = (
|
||||||
|
<AlertDialog
|
||||||
|
open={skillToDelete !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setSkillToDelete(null) }}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.agent.skills.confirm_delete", "Delete Skill?")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"pages.agent.skills.confirm_delete_message",
|
||||||
|
`Are you sure you want to delete "${skillToDelete?.name}"? This action cannot be undone.`,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t("common.delete", "Delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mainContent}
|
||||||
|
{modals}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-full flex-col">
|
||||||
|
<PageHeader title={t("navigation.skills", "Skills")} />
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
|
||||||
|
{mainContent}
|
||||||
|
</div>
|
||||||
|
{modals}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("skills")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||||
|
activeTab === "skills" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconBrain className="size-4" />
|
||||||
|
Skills
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Add IconBrain import
|
||||||
|
import { IconLayoutDashboard, IconUsers, IconBrain } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
// Add skills tab content (after tools section, before agents section)
|
||||||
|
{activeTab === "skills" && <SkillsPage embedded />}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [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?**
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
17
go.mod
17
go.mod
|
|
@ -17,6 +17,7 @@ require (
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
github.com/ergochat/readline v0.1.3
|
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/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
|
@ -76,28 +77,44 @@ require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.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/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.42 // 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/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/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // 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/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // 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/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
|
|
|
||||||
39
go.sum
39
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/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.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
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 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
|
||||||
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
|
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=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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-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.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
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-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 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
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=
|
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/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 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
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/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 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
|
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/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 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
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 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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=
|
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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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/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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
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/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 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
|
|
||||||
288
pkg/agent/manager/manager.go
Normal file
288
pkg/agent/manager/manager.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
48
pkg/agent/manager/types.go
Normal file
48
pkg/agent/manager/types.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
169
pkg/gateway/agent_api.go
Normal file
169
pkg/gateway/agent_api.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -254,6 +254,9 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
}, nil
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register agent API routes
|
||||||
|
RegisterAgentAPI(runningServices.HealthServer)
|
||||||
|
|
||||||
for _, bindHost := range listenResult.BindHosts {
|
for _, bindHost := range listenResult.BindHosts {
|
||||||
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))
|
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
|
mux *http.ServeMux
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ready bool
|
ready bool
|
||||||
checks map[string]Check
|
checks map[string]Check
|
||||||
|
|
@ -46,6 +47,7 @@ func NewServer(host string, port int, token string) *Server {
|
||||||
checks: make(map[string]Check),
|
checks: make(map[string]Check),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
authToken: token,
|
authToken: token,
|
||||||
|
mux: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
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))
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: s.mux,
|
||||||
ReadTimeout: 5 * time.Second,
|
ReadTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 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
|
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.
|
// permissionGrantHandler handles POST /internal/permission/grant requests.
|
||||||
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"path/filepath"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -43,7 +42,7 @@ func LoadToolSkill(filePath string) (*ToolSkill, error) {
|
||||||
// extractTagsFromContent extracts tags from markdown content
|
// extractTagsFromContent extracts tags from markdown content
|
||||||
func extractTagsFromContent(content string) []string {
|
func extractTagsFromContent(content string) []string {
|
||||||
// Look for #tag patterns
|
// Look for #tag patterns
|
||||||
re := strings.NewReplacer(`#([a-zA-Z0-9_-]+)`, `$1`)
|
re := regexp.MustCompile(`#([a-zA-Z0-9_-]+)`)
|
||||||
matches := re.FindAllStringSubmatch(content, -1)
|
matches := re.FindAllStringSubmatch(content, -1)
|
||||||
tags := make([]string, 0, len(matches))
|
tags := make([]string, 0, len(matches))
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
|
|
|
||||||
117
project-map.md
117
project-map.md
|
|
@ -1,5 +1,5 @@
|
||||||
# Project Map
|
# Project Map
|
||||||
_Generated: 2026-05-05 | Git: 07107384_
|
_Generated: 2026-05-07 | Git: $(git rev-parse HEAD 2>/dev/null || echo "local")
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
cmd/ — CLI entry points (picoclaw main, membench, internal subcommands)
|
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/ — 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/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/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
|
config/ — Configuration templates and examples
|
||||||
build/ — Build scripts and artifacts
|
build/ — Build scripts and artifacts
|
||||||
docker/ — Docker containerization files
|
docker/ — Docker containerization files
|
||||||
|
|
@ -20,74 +21,80 @@ assets/ — Static assets (logo, images)
|
||||||
## Key Files
|
## Key Files
|
||||||
cmd/picoclaw/main.go — Main CLI entry point using Cobra; registers subcommands (agent, auth, gateway, mcp, migrate, model, skills, etc.)
|
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)
|
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/context_manager.go — Manages LLM context lifecycle, caching, and budget enforcement
|
||||||
pkg/agent/pipeline.go — Orchestrates agent execution phases (setup → LLM → tools → finalize)
|
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/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/config/ — Configuration loading, validation, and environment variable handling
|
||||||
pkg/skills/ — Skills system for extending agent capabilities
|
pkg/skills/ — Skills system for extending agent capabilities
|
||||||
pkg/tools/ — Built-in tools: filesystem (fs), hardware interaction, shared utilities, integration tools
|
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/mcp/ — Model Context Protocol (MCP) server implementation for tool/resource exposure
|
||||||
pkg/memory/ — Agent memory management (short-term/long-term, persistence)
|
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/auth/ — Authentication and credential management (OAuth, API keys, encryption)
|
||||||
pkg/identity/ — Identity and user/session management
|
pkg/health/ — Health check endpoints and diagnostics server.go
|
||||||
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/bus/ — Internal event bus for decoupled communication
|
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
|
### Backend API (web/backend/api/)
|
||||||
pkg/logger/ — Logging infrastructure
|
- pico.go, router.go — Main API routing
|
||||||
pkg/health/ — Health check endpoints and diagnostics
|
- agents.go — Agent CRUD endpoints (/api/agents, /api/agent/*)
|
||||||
pkg/heartbeat/ — Heartbeat/keepalive mechanism for long-running processes
|
- skills.go — Skills management endpoints (/api/skills, /api/skills/*)
|
||||||
pkg/updater/ — Self-update functionality (minio/selfupdate)
|
|
||||||
pkg/migrate/ — Database and config migration utilities
|
### Frontend (web/frontend/src/)
|
||||||
pkg/media/ — Media processing (images, audio)
|
api/ — REST client wrappers
|
||||||
pkg/audio/asr/ — Automatic Speech Recognition (ASR) providers
|
- agents.ts — Agent API client (listAgents, getAgent, createAgent, updateAgent, deleteAgent)
|
||||||
pkg/audio/tts/ — Text-to-Speech (TTS) providers
|
- skills.ts — Skills API client (listSkills, getSkill, searchSkills, installSkill, deleteSkill)
|
||||||
pkg/tokenizer/ — Token counting and management for LLM context budgets
|
|
||||||
pkg/netbind/ — Network binding utilities for embedded/specific network configs
|
components/agent/
|
||||||
pkg/fileutil/ — File utility functions
|
├── agents/ — Agent management UI
|
||||||
pkg/devices/ — Device management (events, sources) for hardware integrations
|
│ ├── agents-page.tsx — Main agents list with search, create, edit, delete
|
||||||
pkg/isolation/ — Sandboxing and isolation for security
|
│ ├── agent-card.tsx — Agent display card
|
||||||
pkg/seahorse/ — Seahorse integration (encrypted storage)
|
│ └── agent-form-modal.tsx — Create/edit agent form
|
||||||
pkg/constants/ — Package-level constants
|
├── cockpit/ — Main cockpit dashboard
|
||||||
web/backend/api/ — Backend API route definitions
|
│ ├── cockpit-page.tsx — Tools, Skills, Agents tabs with memory graph
|
||||||
web/backend/middleware/ — HTTP middleware (auth, CORS, logging)
|
│ └── use-agent-cockpit.ts — Cockpit state hook (tools, skills, agents, subagents)
|
||||||
web/backend/dashboardauth/ — Dashboard authentication logic
|
├── research/ — Agent research/analysis features
|
||||||
web/backend/model/ — Backend data models
|
│ ├── research-page.tsx — Research dashboard
|
||||||
web/backend/launcherconfig/ — Launcher configuration
|
│ ├── research-config.tsx — Configuration panel
|
||||||
web/frontend/src/ — Frontend source (components, routes, store, features, hooks, lib, api, i18n)
|
│ ├── research-agents.tsx — Agent selection
|
||||||
go.mod — Go 1.25.9 module definition; key deps: Cobra, DiscordGo, Telego, Anthropic SDK, AWS SDK v2, MCP SDK, gRPC, various channel SDKs
|
│ ├── research-reports.tsx — Reports view
|
||||||
go.sum — Dependency checksums
|
│ └── 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)
|
Makefile — Build targets (build, test, lint, release)
|
||||||
.goreleaser.yaml — GoReleaser config for cross-platform releases
|
.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
|
## Critical Constraints
|
||||||
- Target: Runs on $10 hardware (e.g., RISC-V SBCs) with <10MB RAM, core process <20MB for 64MB boards
|
- Target: Ultra-lightweight for $10 hardware with <10MB RAM
|
||||||
- Go 1.25.9 required (very recent version)
|
- Go 1.25.9 required
|
||||||
- Self-bootstrapped: AI Agent drove architecture migration and optimization (not a fork)
|
- Security: Prompt injection defense, tool abuse prevention, SSRF protection
|
||||||
- Memory optimization takes precedence over storage size
|
- Workspace: ~/.picoclaw/workspace/ (agents, skills, memory)
|
||||||
- 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)
|
|
||||||
- Frontend: TypeScript/React with TanStack router/query
|
- 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
|
## 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/
|
||||||
5
tmp.txt
Normal file
5
tmp.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
LS0tCm5hbWU6IFNpbXBsZSBBZ2VudApkZXNjcmlwdGlvbjogQSBzaW1wbGUgdGVz
|
||||||
|
dCBhZ2VudApzeXN0ZW1fcHJvbXB0OiBZb3UgYXJlIGEgaGVscGZ1bCBhc3Npc3Rh
|
||||||
|
bnQuCm1vZGVsOiBxd2VuMy41OjRiCi0tLQ==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -17,6 +18,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -799,7 +801,10 @@ func appendVaultGraph(
|
||||||
noteID := "vault:" + noteName
|
noteID := "vault:" + noteName
|
||||||
|
|
||||||
// Parse frontmatter and content
|
// Parse frontmatter and content
|
||||||
fm, body := memory.ParseFrontmatter(string(data))
|
fm, body, err := memory.ParseFrontmatter(string(data))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Create node
|
// Create node
|
||||||
label := noteName
|
label := noteName
|
||||||
|
|
|
||||||
24
web/backend/dist/index.html
vendored
24
web/backend/dist/index.html
vendored
|
|
@ -9,30 +9,12 @@
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PicoClaw</title>
|
<title>PicoClaw</title>
|
||||||
<script type="module" crossorigin src="/assets/index-C2w0BPus.js"></script>
|
<script type="module" crossorigin src="/assets/index-qfJqQqcR.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/shim-ClcZ9bMo.js">
|
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-m7G7yzlP.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DHuJSidx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BcoH-tuF.css">
|
||||||
<link rel="stylesheet" href="/styles/vault.css">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
<div id="vault-sidebar" class="sidebar hidden">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h3>Vault</h3>
|
|
||||||
<button id="close-sidebar">✕</button>
|
|
||||||
</div>
|
|
||||||
<div id="vault-tree" class="tree-view"></div>
|
|
||||||
<div id="tag-cloud" class="tag-cloud"></div>
|
|
||||||
<div id="session-history" class="session-history"></div>
|
|
||||||
<div id="tool-skills-panel" class="tool-skills-panel"></div>
|
|
||||||
</div>
|
|
||||||
<button id="toggle-sidebar">☰ Vault</button>
|
|
||||||
|
|
||||||
<script src="/vault.js"></script>
|
|
||||||
<script src="/tags.js"></script>
|
|
||||||
<script src="/sessions.js"></script>
|
|
||||||
<script src="/tools-skills.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
32
web/backend/dist/sessions.js
vendored
32
web/backend/dist/sessions.js
vendored
|
|
@ -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 = '<h4>Recent Sessions</h4>';
|
|
||||||
sessions.forEach(session => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'session-item';
|
|
||||||
div.innerHTML = `
|
|
||||||
<div class="session-title">${session.title || 'Untitled'}</div>
|
|
||||||
<div class="session-tags">${(session.tags || []).map(t => `#${t}`).join(' ')}</div>
|
|
||||||
<div class="session-date">${new Date(session.timestamp).toLocaleDateString()}</div>
|
|
||||||
`;
|
|
||||||
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;
|
|
||||||
82
web/backend/dist/styles/vault.css
vendored
82
web/backend/dist/styles/vault.css
vendored
|
|
@ -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);
|
|
||||||
}
|
|
||||||
36
web/backend/dist/tags.js
vendored
36
web/backend/dist/tags.js
vendored
|
|
@ -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 = '<h4>Tags</h4>';
|
|
||||||
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;
|
|
||||||
42
web/backend/dist/tools-skills.js
vendored
42
web/backend/dist/tools-skills.js
vendored
|
|
@ -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 = '<h4>Tool Skills</h4>';
|
|
||||||
skills.forEach(skill => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'skill-item';
|
|
||||||
div.innerHTML = `
|
|
||||||
<div class="skill-name">${skill.name || 'Unknown'}</div>
|
|
||||||
<div class="skill-usage">Used ${skill.usage_count || 0} times</div>
|
|
||||||
<div class="skill-tags">${(skill.tags || []).map(t => `#${t}`).join(' ')}</div>
|
|
||||||
`;
|
|
||||||
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;
|
|
||||||
64
web/backend/dist/vault.js
vendored
64
web/backend/dist/vault.js
vendored
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
@ -3,20 +3,24 @@ import { launcherFetch } from "@/api/http"
|
||||||
export interface SkillSupportItem {
|
export interface SkillSupportItem {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
source: "workspace" | "global" | "builtin" | string
|
source: string
|
||||||
description: string
|
description: string
|
||||||
origin_kind: "builtin" | "third_party" | "manual" | string
|
origin_kind: string
|
||||||
registry_name?: string
|
registry_name?: string
|
||||||
registry_url?: string
|
registry_url?: string
|
||||||
installed_version?: string
|
installed_version?: string
|
||||||
installed_at?: number
|
installed_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillsListResponse {
|
||||||
|
skills: SkillSupportItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillDetailResponse extends SkillSupportItem {
|
export interface SkillDetailResponse extends SkillSupportItem {
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillRegistrySearchResult {
|
export interface SkillSearchResultItem {
|
||||||
score: number
|
score: number
|
||||||
slug: string
|
slug: string
|
||||||
display_name: string
|
display_name: string
|
||||||
|
|
@ -28,25 +32,17 @@ export interface SkillRegistrySearchResult {
|
||||||
installed_name?: string
|
installed_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkillsResponse {
|
|
||||||
skills: SkillSupportItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillSearchResponse {
|
export interface SkillSearchResponse {
|
||||||
results: SkillRegistrySearchResult[]
|
results: SkillSearchResultItem[]
|
||||||
limit: number
|
limit: number
|
||||||
offset: number
|
offset: number
|
||||||
next_offset?: number
|
next_offset?: number
|
||||||
has_more: boolean
|
has_more: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkillActionResponse = Partial<SkillSupportItem> & {
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstallSkillRequest {
|
export interface InstallSkillRequest {
|
||||||
slug: string
|
slug: string
|
||||||
registry: string
|
registry?: string
|
||||||
version?: string
|
version?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -61,90 +57,77 @@ export interface InstallSkillResponse {
|
||||||
skill?: SkillSupportItem
|
skill?: SkillSupportItem
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
export async function listSkills(): Promise<SkillsListResponse> {
|
||||||
const res = await launcherFetch(path, options)
|
const res = await launcherFetch("/api/skills")
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`Failed to list skills: ${res.status}`)
|
||||||
throw new Error(await extractErrorMessage(res))
|
return res.json()
|
||||||
}
|
|
||||||
return res.json() as Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSkills(): Promise<SkillsResponse> {
|
export async function getSkills(): Promise<SkillsListResponse> {
|
||||||
return request<SkillsResponse>("/api/skills")
|
return listSkills()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSkill(name: string): Promise<SkillDetailResponse> {
|
export async function getSkill(name: string): Promise<SkillDetailResponse> {
|
||||||
return request<SkillDetailResponse>(`/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(
|
export interface SkillRegistrySearchResult {
|
||||||
query: string,
|
score: number
|
||||||
limit = 20,
|
slug: string
|
||||||
offset = 0,
|
display_name: string
|
||||||
): Promise<SkillSearchResponse> {
|
summary: string
|
||||||
const params = new URLSearchParams({
|
version: string
|
||||||
q: query,
|
registry_name: string
|
||||||
limit: String(limit),
|
url?: string
|
||||||
offset: String(offset),
|
installed: boolean
|
||||||
})
|
installed_name?: string
|
||||||
return request<SkillSearchResponse>(`/api/skills/search?${params.toString()}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installSkill(
|
export async function searchSkills(query: string, limit = 20, offset = 0): Promise<SkillSearchResponse> {
|
||||||
input: InstallSkillRequest,
|
const params = new URLSearchParams({ q: query })
|
||||||
): Promise<InstallSkillResponse> {
|
if (limit !== 20) params.set("limit", limit.toString())
|
||||||
return request<InstallSkillResponse>("/api/skills/install", {
|
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<InstallSkillResponse> {
|
||||||
|
const res = await launcherFetch("/api/skills/install", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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<SkillActionResponse> {
|
export async function deleteSkill(name: string): Promise<{ status: string }> {
|
||||||
const formData = new FormData()
|
const res = await launcherFetch(`/api/skills/${encodeURIComponent(name)}`, {
|
||||||
formData.set("file", file)
|
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<SkillSupportItem> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
const res = await launcherFetch("/api/skills/import", {
|
const res = await launcherFetch("/api/skills/import", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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<SkillActionResponse>
|
return res.json()
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSkill(name: string): Promise<SkillActionResponse> {
|
|
||||||
return request<SkillActionResponse>(
|
|
||||||
`/api/skills/${encodeURIComponent(name)}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractErrorMessage(res: Response): Promise<string> {
|
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
web/frontend/src/components/agent/agents/agent-card.tsx
Normal file
101
web/frontend/src/components/agent/agents/agent-card.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card
|
||||||
|
className="group relative overflow-hidden transition-all hover:border-[#F27D26]/50 hover:shadow-lg"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-[#F27D26]/30 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||||
|
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight">
|
||||||
|
{agent.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge className={cn("text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5", statusColor)}>
|
||||||
|
{agent.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
|
||||||
|
{agent.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={agent.status === "enabled"}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
className="data-[state=checked]:bg-[#F27D26]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1 text-white/60">
|
||||||
|
<IconRobot className="size-4" />
|
||||||
|
<span className="font-mono">{agent.model}</span>
|
||||||
|
</div>
|
||||||
|
{agent.tool_permissions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-white/60">
|
||||||
|
<IconSettings className="size-4" />
|
||||||
|
<span>{agent.tool_permissions.slice(0, 3).join(", ")}</span>
|
||||||
|
{agent.tool_permissions.length > 3 && (
|
||||||
|
<span className="text-white/40">+{agent.tool_permissions.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-white/5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<IconEdit className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-white/60 hover:text-destructive hover:bg-destructive/10"
|
||||||
|
title={t("common.delete")}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
web/frontend/src/components/agent/agents/agent-form-modal.tsx
Normal file
230
web/frontend/src/components/agent/agents/agent-form-modal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit
|
||||||
|
? t("pages.agent.agents.edit_agent", "Edit Agent")
|
||||||
|
: t("pages.agent.agents.create_agent", "Create Agent")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
{t("pages.agent.agents.form_name", "Name")}
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., researcher, coder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">
|
||||||
|
{t("pages.agent.agents.form_description", "Description")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of what this agent does"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="systemPrompt">
|
||||||
|
{t("pages.agent.agents.form_system_prompt", "System Prompt")}
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="systemPrompt"
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
placeholder="Define the agent's personality, capabilities, and instructions..."
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model">
|
||||||
|
{t("pages.agent.agents.form_model", "Model")}
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="model"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{availableModels.length === 0
|
||||||
|
? t(
|
||||||
|
"pages.agent.agents.no_models_available",
|
||||||
|
"No models available",
|
||||||
|
)
|
||||||
|
: t("pages.agent.agents.select_model", "Select a model")}
|
||||||
|
</option>
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<option key={m.model_name} value={m.model_name}>
|
||||||
|
{m.model_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="toolPermissions">
|
||||||
|
{t("pages.agent.agents.form_tool_permissions", "Tool Permissions")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="toolPermissions"
|
||||||
|
value={toolPermissions}
|
||||||
|
onChange={(e) => setToolPermissions(e.target.value)}
|
||||||
|
placeholder="web_search, file_read, file_write (comma-separated)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"pages.agent.agents.tool_permissions_hint",
|
||||||
|
"List tools this agent can use (comma-separated)",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => onClose(false)}>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>
|
||||||
|
{isEdit
|
||||||
|
? t("common.save", "Save")
|
||||||
|
: t("common.create", "Create")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
web/frontend/src/components/agent/agents/agents-page.tsx
Normal file
245
web/frontend/src/components/agent/agents/agents-page.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { useDeferredValue, useMemo, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteAgent,
|
||||||
|
listAgents,
|
||||||
|
type Agent,
|
||||||
|
updateAgent,
|
||||||
|
} from "@/api/agents"
|
||||||
|
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 { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import { AgentCard } from "./agent-card"
|
||||||
|
import { AgentFormModal } from "./agent-form-modal"
|
||||||
|
|
||||||
|
interface AgentsPageProps {
|
||||||
|
embedded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentsPage({ embedded = false }: AgentsPageProps = {}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||||
|
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null)
|
||||||
|
|
||||||
|
const agentsQuery = useQuery({
|
||||||
|
queryKey: ["agents"],
|
||||||
|
queryFn: listAgents,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
const agents = agentsQuery.data?.agents ?? []
|
||||||
|
const query = deferredSearchQuery.trim().toLowerCase()
|
||||||
|
if (!query) return agents
|
||||||
|
|
||||||
|
return agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.name.toLowerCase().includes(query) ||
|
||||||
|
agent.description.toLowerCase().includes(query) ||
|
||||||
|
agent.slug.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [agentsQuery.data?.agents, deferredSearchQuery])
|
||||||
|
|
||||||
|
if (agentsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Loading agents...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<p className="text-destructive">Failed to load agents. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (agent: Agent) => {
|
||||||
|
setSelectedAgent(agent)
|
||||||
|
setIsEditModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!agentToDelete) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAgent(agentToDelete.slug)
|
||||||
|
toast.success(t("pages.agent.agents.delete_success", "Agent deleted"))
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to delete agent",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAgentToDelete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (agent: Agent, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await updateAgent(agent.slug, { status: enabled ? "enabled" : "disabled" })
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? t("pages.agent.agents.enable_success", "Agent enabled")
|
||||||
|
: t("pages.agent.agents.disable_success", "Agent disabled"),
|
||||||
|
)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to update agent",
|
||||||
|
)
|
||||||
|
// Refresh to revert the UI state
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModalClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setIsCreateModalOpen(false)
|
||||||
|
setIsEditModalOpen(false)
|
||||||
|
setSelectedAgent(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContent = (
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
{/* Header Controls */}
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-md flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t("common.search", "Search agents...")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="bg-[#F27D26] hover:bg-[#F27D26]/90 text-black"
|
||||||
|
>
|
||||||
|
{t("pages.agent.agents.create_agent", "+ Create Agent")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Grid */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filteredAgents.length === 0 ? (
|
||||||
|
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{deferredSearchQuery
|
||||||
|
? t("pages.agent.agents.no_results", "No agents found")
|
||||||
|
: t("pages.agent.agents.no_agents", "No agents yet")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground/60">
|
||||||
|
{deferredSearchQuery
|
||||||
|
? t("pages.agent.agents.no_results_hint", "Try a different search")
|
||||||
|
: t("pages.agent.agents.no_agents_hint", "Create an agent to get started")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAgents.map((agent) => (
|
||||||
|
<AgentCard
|
||||||
|
key={agent.slug}
|
||||||
|
agent={agent}
|
||||||
|
onEdit={() => handleEdit(agent)}
|
||||||
|
onDelete={() => setAgentToDelete(agent)}
|
||||||
|
onToggle={(enabled) => handleToggle(agent, enabled)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modals = (
|
||||||
|
<>
|
||||||
|
{/* Create Modal */}
|
||||||
|
<AgentFormModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => handleModalClose(false)}
|
||||||
|
onSave={() =>
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<AgentFormModal
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={() => handleModalClose(false)}
|
||||||
|
agent={selectedAgent}
|
||||||
|
onSave={() =>
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog
|
||||||
|
open={agentToDelete !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setAgentToDelete(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.agent.agents.confirm_delete", "Delete Agent?")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"pages.agent.agents.confirm_delete_message",
|
||||||
|
`Are you sure you want to delete "${agentToDelete?.name}"? This action cannot be undone.`,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t("common.delete", "Delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mainContent}
|
||||||
|
{modals}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-full flex-col">
|
||||||
|
<PageHeader title={t("navigation.agents", "Agents")} />
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
|
||||||
|
{mainContent}
|
||||||
|
</div>
|
||||||
|
{modals}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
web/frontend/src/components/agent/agents/index.ts
Normal file
3
web/frontend/src/components/agent/agents/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { AgentCard } from "./agent-card"
|
||||||
|
export { AgentFormModal } from "./agent-form-modal"
|
||||||
|
export { AgentsPage } from "./agents-page"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { IconArrowRight } from "@tabler/icons-react"
|
import { IconArrowRight, IconLayoutDashboard, IconUsers, IconBrain, IconFlask } from "@tabler/icons-react"
|
||||||
|
|
||||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
@ -9,6 +9,9 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import { MemoryGraph } from "./memory-graph"
|
import { MemoryGraph } from "./memory-graph"
|
||||||
import { useAgentCockpit } from "./use-agent-cockpit"
|
import { useAgentCockpit } from "./use-agent-cockpit"
|
||||||
|
import { AgentsPage } from "../agents"
|
||||||
|
import { SkillsPage } from "../skills"
|
||||||
|
import { ResearchPage } from "../research/research-page"
|
||||||
|
|
||||||
function reasonLabel(reasonCode?: string) {
|
function reasonLabel(reasonCode?: string) {
|
||||||
switch (reasonCode) {
|
switch (reasonCode) {
|
||||||
|
|
@ -35,6 +38,8 @@ export function CockpitPage() {
|
||||||
sessionSubagents,
|
sessionSubagents,
|
||||||
sessionMemoryGraph,
|
sessionMemoryGraph,
|
||||||
toggleTool,
|
toggleTool,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
} = useAgentCockpit(activeSessionId)
|
} = useAgentCockpit(activeSessionId)
|
||||||
|
|
||||||
const filteredToolCount = useMemo(
|
const filteredToolCount = useMemo(
|
||||||
|
|
@ -77,6 +82,56 @@ export function CockpitPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 border-b border-white/10 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("tools")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||||
|
activeTab === "tools" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconLayoutDashboard className="size-4" />
|
||||||
|
Tools
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("skills")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||||
|
activeTab === "skills" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconBrain className="size-4" />
|
||||||
|
Skills
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("agents")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||||
|
activeTab === "agents" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconUsers className="size-4" />
|
||||||
|
Agents
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("research")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs uppercase tracking-widest font-bold transition-colors",
|
||||||
|
activeTab === "research" ? "text-[#F27D26] border-b-2 border-[#F27D26] pb-4 -mb-4.5" : "text-white/40 hover:text-white/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconFlask className="size-4" />
|
||||||
|
Research
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "skills" && <SkillsPage embedded />}
|
||||||
|
|
||||||
|
{activeTab === "agents" && <AgentsPage embedded />}
|
||||||
|
|
||||||
|
{activeTab === "research" && <ResearchPage />}
|
||||||
|
|
||||||
|
{activeTab === "tools" && (
|
||||||
<section className="grid gap-12">
|
<section className="grid gap-12">
|
||||||
{/* Tool Grid */}
|
{/* Tool Grid */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
@ -151,6 +206,7 @@ export function CockpitPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar */}
|
{/* Right Sidebar */}
|
||||||
|
|
@ -161,7 +217,11 @@ export function CockpitPage() {
|
||||||
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
|
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sessionSubagents.length === 0 ? (
|
{sessionSubagents === null ? (
|
||||||
|
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
|
||||||
|
Loading subagents...
|
||||||
|
</div>
|
||||||
|
) : sessionSubagents.length === 0 ? (
|
||||||
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
|
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
|
||||||
No subagents have been created in this session yet.
|
No subagents have been created in this session yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getPicoMemoryGraph, getPicoSubagents } from "@/api/pico"
|
||||||
import { getTools, getWebSearchConfig, setToolEnabled } from "@/api/tools"
|
import { getTools, getWebSearchConfig, setToolEnabled } from "@/api/tools"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
import { useCockpitSkills } from "@/hooks/use-cockpit-skills"
|
||||||
|
|
||||||
type ToolStatusFilter = "all" | "enabled" | "disabled" | "blocked"
|
type ToolStatusFilter = "all" | "enabled" | "disabled" | "blocked"
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ export function useAgentCockpit(sessionId: string) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
|
||||||
|
const [activeTab, setActiveTab] = useState<"tools" | "skills" | "agents" | "research">("tools")
|
||||||
const deferredSearchQuery = useDeferredValue(searchQuery)
|
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||||
|
|
||||||
const toolsQuery = useQuery({
|
const toolsQuery = useQuery({
|
||||||
|
|
@ -38,6 +40,12 @@ export function useAgentCockpit(sessionId: string) {
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
skills,
|
||||||
|
isLoading: skillsLoading,
|
||||||
|
isError: skillsError,
|
||||||
|
} = useCockpitSkills()
|
||||||
|
|
||||||
const toggleToolMutation = useMutation({
|
const toggleToolMutation = useMutation({
|
||||||
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
|
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
|
||||||
setToolEnabled(name, enabled),
|
setToolEnabled(name, enabled),
|
||||||
|
|
@ -120,6 +128,8 @@ export function useAgentCockpit(sessionId: string) {
|
||||||
statusCounts,
|
statusCounts,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
tools,
|
tools,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
hasMemoryGraphError: memoryGraphQuery.error != null,
|
hasMemoryGraphError: memoryGraphQuery.error != null,
|
||||||
webSearchConfig: webSearchQuery.data ?? null,
|
webSearchConfig: webSearchQuery.data ?? null,
|
||||||
hasSubagentsError: subagentsQuery.error != null,
|
hasSubagentsError: subagentsQuery.error != null,
|
||||||
|
|
@ -128,6 +138,9 @@ export function useAgentCockpit(sessionId: string) {
|
||||||
isSubagentsLoading: subagentsQuery.isLoading,
|
isSubagentsLoading: subagentsQuery.isLoading,
|
||||||
isToolsLoading: toolsQuery.isLoading,
|
isToolsLoading: toolsQuery.isLoading,
|
||||||
isWebSearchLoading: webSearchQuery.isLoading,
|
isWebSearchLoading: webSearchQuery.isLoading,
|
||||||
|
skills,
|
||||||
|
skillsLoading,
|
||||||
|
skillsError,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
toggleTool: (name: string, enabled: boolean) =>
|
toggleTool: (name: string, enabled: boolean) =>
|
||||||
|
|
|
||||||
144
web/frontend/src/components/agent/research/research-agents.tsx
Normal file
144
web/frontend/src/components/agent/research/research-agents.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { IconBook, IconDatabase, IconCircleCheck, IconSparkles } 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
|
||||||
|
active: boolean
|
||||||
|
progress: number
|
||||||
|
ram: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchAgentsProps {
|
||||||
|
agents: ResearchAgent[]
|
||||||
|
onToggleAgent: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
literature: IconBook,
|
||||||
|
extractor: IconDatabase,
|
||||||
|
validator: IconCircleCheck,
|
||||||
|
synthesizer: IconSparkles,
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentLabels: Record<string, string> = {
|
||||||
|
literature: "Literature Analyzer",
|
||||||
|
extractor: "Data Extractor",
|
||||||
|
validator: "Fact Validator",
|
||||||
|
synthesizer: "Synthesizer",
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
literature: "Analyzing papers",
|
||||||
|
extractor: "Extracting data",
|
||||||
|
validator: "Validating facts",
|
||||||
|
synthesizer: "Synthesizing",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResearchAgents({ agents, onToggleAgent }: ResearchAgentsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 pb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">
|
||||||
|
Research Agents
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-white/40 font-mono">
|
||||||
|
{agents.filter(a => a.active).length}/{agents.length} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const Icon = agentIcons[agent.id] || IconBook
|
||||||
|
const isComplete = agent.progress > 90
|
||||||
|
const isProcessing = agent.progress > 50
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className={cn(
|
||||||
|
"group relative rounded-xl border p-4 transition-all cursor-pointer",
|
||||||
|
agent.active
|
||||||
|
? "border-white/20 bg-[#0A0A0A] hover:border-[#F27D26]/50"
|
||||||
|
: "border-white/5 bg-[#050505] opacity-60"
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleAgent(agent.id)}
|
||||||
|
>
|
||||||
|
{/* Active glow effect */}
|
||||||
|
{agent.active && (
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-[#F27D26]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||||
|
agent.active
|
||||||
|
? "bg-gradient-to-br from-[#F27D26] to-[#e05a10]"
|
||||||
|
: "bg-white/10"
|
||||||
|
)}>
|
||||||
|
<Icon className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-[#F2F2F2]">
|
||||||
|
{agentLabels[agent.id] || agent.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-white/40 flex items-center gap-1 mt-0.5">
|
||||||
|
<span className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full",
|
||||||
|
agent.active ? "bg-green-500 animate-pulse" : "bg-white/20"
|
||||||
|
)} />
|
||||||
|
{agent.active ? (statusLabels[agent.id] || "Running") : "Stopped"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={agent.active}
|
||||||
|
disabled={false}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onCheckedChange={() => onToggleAgent(agent.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-[10px]">
|
||||||
|
<span className="text-white/40">Progress</span>
|
||||||
|
<span className="text-[#F27D26] font-semibold">{agent.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-[#F27D26] to-[#fb923c] rounded-full transition-all"
|
||||||
|
style={{ width: `${agent.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<div className="text-[10px]">
|
||||||
|
<span className="text-white/40">Memory</span>
|
||||||
|
<span className="ml-1.5 text-[#F2F2F2] font-medium">{agent.ram}</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] px-2 py-0.5 rounded-none font-bold uppercase",
|
||||||
|
isComplete
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: isProcessing
|
||||||
|
? "bg-[#F27D26]/20 text-[#F27D26]"
|
||||||
|
: "bg-white/10 text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isComplete ? "Finalizing" : isProcessing ? "Processing" : "Starting"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
web/frontend/src/components/agent/research/research-config.tsx
Normal file
149
web/frontend/src/components/agent/research/research-config.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { IconShieldCheck } 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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Configuration Panel */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b border-white/10 pb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">
|
||||||
|
Configuration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#0A0A0A] p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-medium text-white/40 uppercase tracking-wide block mb-2">
|
||||||
|
Research Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2.5 rounded-lg bg-[#050505] border border-white/10 text-[#F2F2F2] text-xs focus:outline-none focus:border-[#F27D26] transition-colors cursor-pointer"
|
||||||
|
value={researchType}
|
||||||
|
onChange={(e) => setResearchType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="1.0">Literature Review</option>
|
||||||
|
<option value="1.5">Systematic</option>
|
||||||
|
<option value="2.0">Meta-analysis</option>
|
||||||
|
<option value="0.8">Exploratory</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-medium text-white/40 uppercase tracking-wide block mb-2">
|
||||||
|
Depth Level
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2.5 rounded-lg bg-[#050505] border border-white/10 text-[#F2F2F2] text-xs focus:outline-none focus:border-[#F27D26] transition-colors cursor-pointer"
|
||||||
|
value={depth}
|
||||||
|
onChange={(e) => setDepth(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="0.8">Shallow</option>
|
||||||
|
<option value="1.5">Deep</option>
|
||||||
|
<option value="2.2">Ultra</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-3 px-4 rounded-lg bg-[#F27D26]/5 border border-[#F27D26]/20">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#F27D26] font-semibold">
|
||||||
|
<IconShieldCheck className="w-4 h-4" />
|
||||||
|
Restrict to Graph
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={restrictToGraph}
|
||||||
|
onCheckedChange={setRestrictToGraph}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scope Calculator */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b border-white/10 pb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">
|
||||||
|
Report Scope
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[#0A0A0A] p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-[#F27D26] mb-1">{scope.pages}</div>
|
||||||
|
<div className="text-[10px] text-white/40 uppercase tracking-wide">Pages</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-[#F2F2F2] mb-1">
|
||||||
|
{(scope.words / 1000).toFixed(1)}k
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-white/40 uppercase tracking-wide">Words</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 my-4" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs font-semibold mb-1",
|
||||||
|
scope.complexity === "High" ? "text-[#f59e0b]" :
|
||||||
|
scope.complexity === "Moderate" ? "text-[#F27D26]" : "text-green-400"
|
||||||
|
)}>
|
||||||
|
{scope.complexity}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-white/40 uppercase tracking-wide">Complexity</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs font-semibold text-[#F2F2F2] mb-1">{scope.time} min</div>
|
||||||
|
<div className="text-[10px] text-white/40 uppercase tracking-wide">Est. Time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-[#F27D26] to-[#fb923c] text-black text-xs font-bold hover:from-[#ff8f4a] hover:to-[#fca55a] transition-all shadow-lg shadow-[#F27D26]/20">
|
||||||
|
Start Research
|
||||||
|
</button>
|
||||||
|
<button className="w-full px-4 py-2.5 rounded-lg bg-[#050505] border border-white/10 text-white/60 text-xs font-medium hover:border-white/30 hover:text-white transition-all">
|
||||||
|
Advanced Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
web/frontend/src/components/agent/research/research-graph.tsx
Normal file
209
web/frontend/src/components/agent/research/research-graph.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
interface ResearchNode {
|
||||||
|
name: string
|
||||||
|
abbr: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchGraphProps {
|
||||||
|
nodes: ResearchNode[]
|
||||||
|
selectedNodes: Set<string>
|
||||||
|
onNodeToggle: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWBOX_WIDTH = 800
|
||||||
|
const VIEWBOX_HEIGHT = 500
|
||||||
|
|
||||||
|
export function ResearchGraph({ nodes, selectedNodes, onNodeToggle }: ResearchGraphProps) {
|
||||||
|
const [hoveredNode, setHoveredNode] = useState<string | null>(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 (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-[#0A0A0A]">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||||
|
className="h-[420px] w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="Research knowledge graph"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="researchGlow">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="researchGrid" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#0c1a14" />
|
||||||
|
<stop offset="100%" stopColor="#050a08" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#researchGrid)" />
|
||||||
|
|
||||||
|
{/* Grid lines */}
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<line
|
||||||
|
key={`v-${index}`}
|
||||||
|
x1={(VIEWBOX_WIDTH / 8) * index}
|
||||||
|
y1="0"
|
||||||
|
x2={(VIEWBOX_WIDTH / 8) * index}
|
||||||
|
y2={VIEWBOX_HEIGHT}
|
||||||
|
stroke="#0d2817"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<line
|
||||||
|
key={`h-${index}`}
|
||||||
|
x1="0"
|
||||||
|
y1={(VIEWBOX_HEIGHT / 6) * index}
|
||||||
|
x2={VIEWBOX_WIDTH}
|
||||||
|
y2={(VIEWBOX_HEIGHT / 6) * index}
|
||||||
|
stroke="#0d2817"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Connections */}
|
||||||
|
{connections.map((conn, i) => (
|
||||||
|
<line
|
||||||
|
key={`conn-${i}`}
|
||||||
|
x1={conn.from.x}
|
||||||
|
y1={conn.from.y}
|
||||||
|
x2={conn.to.x}
|
||||||
|
y2={conn.to.y}
|
||||||
|
stroke="#1f5c34"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeOpacity="0.5"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Center knowledge base node */}
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
cx="400"
|
||||||
|
cy="200"
|
||||||
|
r="30"
|
||||||
|
fill="#10b981"
|
||||||
|
opacity="0.1"
|
||||||
|
filter="url(#researchGlow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="400"
|
||||||
|
cy="200"
|
||||||
|
r="18"
|
||||||
|
fill="#07110a"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="400"
|
||||||
|
y="203"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#10b981"
|
||||||
|
fontSize="11"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||||
|
>
|
||||||
|
KB
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Knowledge nodes */}
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const isSelected = selectedNodes.has(node.name)
|
||||||
|
const isHovered = hoveredNode === node.name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.name}
|
||||||
|
onClick={() => onNodeToggle(node.name)}
|
||||||
|
onMouseEnter={() => setHoveredNode(node.name)}
|
||||||
|
onMouseLeave={() => setHoveredNode(null)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Outer glow */}
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r="22"
|
||||||
|
fill={isSelected ? "#10b981" : "#F27D26"}
|
||||||
|
opacity={isSelected || isHovered ? "0.15" : "0.08"}
|
||||||
|
filter="url(#researchGlow)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main node */}
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r="14"
|
||||||
|
fill="#07110a"
|
||||||
|
stroke={isSelected ? "#10b981" : "#F27D26"}
|
||||||
|
strokeWidth={isSelected || isHovered ? "2.5" : "1.5"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inner glow */}
|
||||||
|
<circle
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r="10"
|
||||||
|
fill={isSelected ? "#10b981" : "#F27D26"}
|
||||||
|
opacity="0.12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<text
|
||||||
|
x={node.x}
|
||||||
|
y={node.y + 3}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={isSelected ? "#10b981" : "#F27D26"}
|
||||||
|
fontSize="9"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||||
|
>
|
||||||
|
{node.abbr}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Tooltip on hover */}
|
||||||
|
{(isHovered || isSelected) && (
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
x={node.x - 40}
|
||||||
|
y={node.y - 38}
|
||||||
|
width="80"
|
||||||
|
height="16"
|
||||||
|
rx="3"
|
||||||
|
fill="#0A0A0A"
|
||||||
|
stroke="#1f5c34"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={node.x}
|
||||||
|
y={node.y - 27}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#95d7a5"
|
||||||
|
fontSize="8"
|
||||||
|
>
|
||||||
|
{node.name.slice(0, 12)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
web/frontend/src/components/agent/research/research-page.tsx
Normal file
188
web/frontend/src/components/agent/research/research-page.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { IconBook, IconDatabase, IconCircleCheck, IconSparkles, IconShield, IconActivity, IconCpu, IconFileText, IconSettings } from "@tabler/icons-react"
|
||||||
|
import { ResearchAgents } from "./research-agents"
|
||||||
|
import { ResearchGraph } from "./research-graph"
|
||||||
|
import { ResearchConfig } from "./research-config"
|
||||||
|
import { ResearchReports } from "./research-reports"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
interface ResearchAgent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
active: boolean
|
||||||
|
progress: number
|
||||||
|
ram: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchReport {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: "in-progress" | "complete"
|
||||||
|
timestamp: string
|
||||||
|
pages?: number
|
||||||
|
words?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchNode {
|
||||||
|
name: string
|
||||||
|
abbr: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAgents: ResearchAgent[] = [
|
||||||
|
{ id: "literature", name: "Literature Analyzer", icon: IconBook, active: true, progress: 94, ram: "2.4GB" },
|
||||||
|
{ id: "extractor", name: "Data Extractor", icon: IconDatabase, active: true, progress: 87, ram: "1.8GB" },
|
||||||
|
{ id: "validator", name: "Fact Validator", icon: IconCircleCheck, active: true, progress: 76, ram: "1.2GB" },
|
||||||
|
{ id: "synthesizer", name: "Synthesizer", icon: IconSparkles, active: false, progress: 65, ram: "0.9GB" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultReports: ResearchReport[] = [
|
||||||
|
{ id: "1", title: "AI trends 2026", status: "in-progress", timestamp: "2 min ago", pages: 24, words: 7200 },
|
||||||
|
{ id: "2", title: "Quantum computing", status: "complete", timestamp: "1 hour ago", pages: 45, words: 13500 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultNodes: ResearchNode[] = [
|
||||||
|
{ name: "Neural Networks", abbr: "NN", x: 150, y: 80 },
|
||||||
|
{ name: "Transformers", abbr: "TR", x: 150, y: 120 },
|
||||||
|
{ name: "LLM Optimization", abbr: "LO", x: 150, y: 160 },
|
||||||
|
{ name: "Edge Computing", abbr: "EC", x: 150, y: 210 },
|
||||||
|
{ name: "Multi-Agent Systems", abbr: "MA", x: 150, y: 260 },
|
||||||
|
{ name: "Vision Models", abbr: "VM", x: 650, y: 100 },
|
||||||
|
{ name: "RAG Systems", abbr: "RA", x: 650, y: 200 },
|
||||||
|
{ name: "Knowledge Graphs", abbr: "KG", x: 400, y: 150 },
|
||||||
|
{ name: "Agent Architecture", abbr: "AA", x: 400, y: 180 },
|
||||||
|
{ name: "Fine-tuning Methods", abbr: "FT", x: 400, y: 250 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ResearchPage() {
|
||||||
|
const [agents, setAgents] = useState<ResearchAgent[]>(defaultAgents)
|
||||||
|
const [researchType, setResearchType] = useState<string>("1.5")
|
||||||
|
const [depth, setDepth] = useState<string>("1.5")
|
||||||
|
const [restrictToGraph, setRestrictToGraph] = useState(false)
|
||||||
|
const [selectedNodes, setSelectedNodes] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const handleToggleAgent = (id: string) => {
|
||||||
|
setAgents(agents.map(agent =>
|
||||||
|
agent.id === id ? { ...agent, active: !agent.active } : agent
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeAgents = agents.filter(a => a.active)
|
||||||
|
const totalProgress = activeAgents.length > 0
|
||||||
|
? Math.round(activeAgents.reduce((sum, a) => sum + a.progress, 0) / activeAgents.length)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen bg-[#050505] overflow-hidden">
|
||||||
|
{/* Ghost Background Typography */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-[200px] font-black text-[#F27D26]/[0.03] tracking-[0.3em] leading-none select-none">
|
||||||
|
RESEARCH
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative z-10 border-b border-white/10 bg-[#0A0A0A]/80 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#F27D26] to-[#e05a10] flex items-center justify-center">
|
||||||
|
<IconShield className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-[#F2F2F2]">Research Mode</h1>
|
||||||
|
<p className="text-[10px] text-white/40">AI-Powered Research Assistant</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconActivity className="w-4 h-4 text-[#F27D26]" />
|
||||||
|
<span className="text-xs text-white/60">Status:</span>
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 text-[10px] px-2">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconCpu className="w-4 h-4 text-white/40" />
|
||||||
|
<span className="text-xs text-white/60">Progress:</span>
|
||||||
|
<span className="text-sm font-semibold text-[#F27D26]">{totalProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconFileText className="w-4 h-4 text-white/40" />
|
||||||
|
<span className="text-xs text-white/60">Reports:</span>
|
||||||
|
<span className="text-sm font-semibold text-[#F2F2F2]">{defaultReports.filter(r => r.status === "complete").length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content - 3 Column Layout */}
|
||||||
|
<main className="relative z-10 flex h-[calc(100vh-130px)]">
|
||||||
|
{/* Left Column - Agents */}
|
||||||
|
<div className="w-80 border-r border-white/10 bg-[#0A0A0A]/50 p-4 overflow-y-auto">
|
||||||
|
<ResearchAgents
|
||||||
|
agents={agents}
|
||||||
|
onToggleAgent={handleToggleAgent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center Column - Graph */}
|
||||||
|
<div className="flex-1 bg-[#050505] relative">
|
||||||
|
<ResearchGraph
|
||||||
|
nodes={defaultNodes}
|
||||||
|
selectedNodes={selectedNodes}
|
||||||
|
onNodeToggle={(name: string) => {
|
||||||
|
setSelectedNodes(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name)
|
||||||
|
} else {
|
||||||
|
next.add(name)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Config + Reports */}
|
||||||
|
<div className="w-80 border-l border-white/10 bg-[#0A0A0A]/50 p-4 overflow-y-auto flex flex-col gap-6">
|
||||||
|
<ResearchConfig
|
||||||
|
researchType={researchType}
|
||||||
|
setResearchType={setResearchType}
|
||||||
|
depth={depth}
|
||||||
|
setDepth={setDepth}
|
||||||
|
restrictToGraph={restrictToGraph}
|
||||||
|
setRestrictToGraph={setRestrictToGraph}
|
||||||
|
/>
|
||||||
|
<ResearchReports
|
||||||
|
reports={defaultReports}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="relative z-10 border-t border-white/10 bg-[#0A0A0A]/80 px-6 py-2">
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-white/40">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>PicoClaw Research Engine v2.4.1</span>
|
||||||
|
<span className="text-white/20">|</span>
|
||||||
|
<span>Nodes: {defaultNodes.length}</span>
|
||||||
|
<span className="text-white/20">|</span>
|
||||||
|
<span>Agents: {activeAgents.length} active</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconSettings className="w-3 h-3" />
|
||||||
|
<span>Last updated: {new Date().toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { IconCheck, IconClock } from "@tabler/icons-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ResearchReport {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: "in-progress" | "complete"
|
||||||
|
timestamp: string
|
||||||
|
pages?: number
|
||||||
|
words?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchReportsProps {
|
||||||
|
reports: ResearchReport[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResearchReports({ reports }: ResearchReportsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 pb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">
|
||||||
|
Recent Reports
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-white/40 font-mono">
|
||||||
|
{reports.length} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div
|
||||||
|
key={report.id}
|
||||||
|
className="rounded-lg border border-white/10 bg-[#0A0A0A] p-3 hover:border-white/20 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0",
|
||||||
|
report.status === "complete"
|
||||||
|
? "bg-green-500/20"
|
||||||
|
: "bg-[#F27D26]/20"
|
||||||
|
)}>
|
||||||
|
{report.status === "complete" ? (
|
||||||
|
<IconCheck className="w-4 h-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<IconClock className="w-4 h-4 text-[#F27D26]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-[#F2F2F2] truncate">
|
||||||
|
{report.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-[10px] text-white/40">
|
||||||
|
{report.timestamp}
|
||||||
|
</span>
|
||||||
|
{report.pages && (
|
||||||
|
<>
|
||||||
|
<span className="text-white/20">•</span>
|
||||||
|
<span className="text-[10px] text-white/40">
|
||||||
|
{report.pages} pages
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full px-3 py-2 rounded-lg bg-[#050505] border border-white/10 text-[10px] text-white/60 font-medium hover:border-white/30 hover:text-white transition-all">
|
||||||
|
View All Reports
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
web/frontend/src/components/agent/skills/index.ts
Normal file
2
web/frontend/src/components/agent/skills/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { SkillCard } from "./skill-card"
|
||||||
|
export { SkillsPage } from "./skills-page"
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { IconFileInfo, IconTrash } from "@tabler/icons-react"
|
import { IconTrash, IconWorld, IconFolder } from "@tabler/icons-react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { SkillSupportItem } from "@/api/skills"
|
import type { SkillSupportItem } from "@/api/skills"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,22 +9,36 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface SkillCardProps {
|
interface SkillCardProps {
|
||||||
skill: SkillSupportItem
|
skill: SkillSupportItem
|
||||||
onView: () => void
|
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onView?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
|
export function SkillCard({ skill, onDelete, onView: _onView }: SkillCardProps) {
|
||||||
|
void _onView // avoid unused warning
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Card
|
<Card size="sm">
|
||||||
className="group border-border/40 bg-card/40 hover:bg-card hover:border-border/80 relative overflow-hidden transition-all hover:shadow-md"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<div className="via-primary/10 absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
|
@ -33,51 +46,49 @@ export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
|
||||||
<CardTitle className="text-base font-semibold tracking-tight">
|
<CardTitle className="text-base font-semibold tracking-tight">
|
||||||
{skill.name}
|
{skill.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{skill.registry_name ? (
|
<Badge className={cn("text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5", kindColor)}>
|
||||||
<span className="bg-muted/60 text-muted-foreground ring-border/50 inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold tracking-wider uppercase ring-1 ring-inset">
|
{originKindLabel(skill.origin_kind)}
|
||||||
{skill.registry_name}
|
</Badge>
|
||||||
</span>
|
{skill.installed_version && (
|
||||||
) : null}
|
<Badge className="bg-white/10 text-white/60 text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5">
|
||||||
|
v{skill.installed_version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
|
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
|
||||||
{skill.description || t("pages.agent.skills.no_description")}
|
{skill.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
onClick={onView}
|
|
||||||
title={t("pages.agent.skills.view")}
|
|
||||||
>
|
|
||||||
<IconFileInfo className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{skill.source === "workspace" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
onClick={onDelete}
|
|
||||||
title={t("pages.agent.skills.delete")}
|
|
||||||
>
|
|
||||||
<IconTrash className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-0">
|
||||||
{skill.registry_url ? (
|
<div className="flex items-center justify-between">
|
||||||
<a
|
<div className="flex items-center gap-3 text-xs text-white/60">
|
||||||
href={skill.registry_url}
|
{skill.registry_name && (
|
||||||
target="_blank"
|
<span className="flex items-center gap-1">
|
||||||
rel="noreferrer"
|
<IconWorld className="size-3.5" />
|
||||||
className="text-primary/80 hover:text-primary inline-flex items-center text-xs transition-colors hover:underline hover:underline-offset-4"
|
{skill.registry_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{skill.source && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<IconFolder className="size-3.5" />
|
||||||
|
{skill.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{skill.origin_kind === "manual" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-white/60 hover:text-destructive hover:bg-destructive/10"
|
||||||
|
title={t("common.delete")}
|
||||||
>
|
>
|
||||||
{skill.registry_url}
|
<IconTrash className="size-3.5" />
|
||||||
</a>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,160 @@
|
||||||
import { IconLoader2, IconPlus } from "@tabler/icons-react"
|
import { useDeferredValue, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import type { SkillSupportItem } from "@/api/skills"
|
||||||
|
import { useCockpitSkills } from "@/hooks/use-cockpit-skills"
|
||||||
import { PageHeader } from "@/components/page-header"
|
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"
|
||||||
|
|
||||||
import { DeleteDialog } from "./delete-dialog"
|
interface SkillsPageProps {
|
||||||
import { DetailSheet } from "./detail-sheet"
|
embedded?: boolean
|
||||||
import { FilterBar } from "./filter-bar"
|
}
|
||||||
import { ImportDialog } from "./import-dialog"
|
|
||||||
import { PageSkeleton } from "./page-skeleton"
|
|
||||||
import { SkillsList } from "./skills-list"
|
|
||||||
import { Stats } from "./stats"
|
|
||||||
import { useSkillsPage } from "./use-skills-page"
|
|
||||||
|
|
||||||
export function SkillsPage() {
|
export function SkillsPage({ embedded = false }: SkillsPageProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
searchQuery,
|
skills,
|
||||||
sourceFilter,
|
|
||||||
sortOrder,
|
|
||||||
layoutMode,
|
|
||||||
detailView,
|
|
||||||
isDragActive,
|
|
||||||
isImportDialogOpen,
|
|
||||||
selectedSkill,
|
|
||||||
skillPendingDelete,
|
|
||||||
availableOrigins,
|
|
||||||
groupedSkills,
|
|
||||||
stats,
|
|
||||||
sortedSkills,
|
|
||||||
hasActiveFilters,
|
|
||||||
importInputRef,
|
|
||||||
selectedSkillDetail,
|
|
||||||
skillsError,
|
|
||||||
skillDetailError,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isSkillDetailLoading,
|
isError,
|
||||||
isImportPending,
|
deleteSkill,
|
||||||
isDeletePending,
|
} = useCockpitSkills()
|
||||||
setSearchQuery,
|
|
||||||
setSourceFilter,
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
setSortOrder,
|
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||||
setLayoutMode,
|
const [skillToDelete, setSkillToDelete] = useState<SkillSupportItem | null>(null)
|
||||||
setDetailView,
|
|
||||||
openImportDialog,
|
const filteredSkills = useMemo(() => {
|
||||||
handleViewSkill,
|
const query = deferredSearchQuery.trim().toLowerCase()
|
||||||
handleRequestDelete,
|
if (!query) return skills
|
||||||
handleConfirmDelete,
|
return skills.filter(
|
||||||
handleImportClick,
|
(skill) =>
|
||||||
handleImportFileChange,
|
skill.name.toLowerCase().includes(query) ||
|
||||||
handleDropZoneDragEnter,
|
skill.description.toLowerCase().includes(query) ||
|
||||||
handleDropZoneDragLeave,
|
skill.origin_kind.toLowerCase().includes(query)
|
||||||
handleDropZoneDrop,
|
)
|
||||||
handleDetailSheetOpenChange,
|
}, [skills, deferredSearchQuery])
|
||||||
handleImportDialogOpenChange,
|
|
||||||
handleDeleteDialogOpenChange,
|
const handleDelete = async () => {
|
||||||
} = useSkillsPage()
|
if (!skillToDelete) return
|
||||||
|
try {
|
||||||
|
await deleteSkill(skillToDelete.name)
|
||||||
|
} catch {
|
||||||
|
// Error handled by hook
|
||||||
|
} finally {
|
||||||
|
setSkillToDelete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Loading skills...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<p className="text-destructive">Failed to load skills. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContent = (
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-md flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t("common.search", "Search skills...")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filteredSkills.length === 0 ? (
|
||||||
|
<div className="col-span-full rounded-lg border border-dashed border-white/10 p-12 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{deferredSearchQuery
|
||||||
|
? t("pages.agent.skills.no_results", "No skills found")
|
||||||
|
: t("pages.agent.skills.no_skills", "No skills installed")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground/60">
|
||||||
|
{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")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSkills.map((skill) => (
|
||||||
|
<SkillCard
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
onDelete={() => setSkillToDelete(skill)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const modals = (
|
||||||
|
<AlertDialog
|
||||||
|
open={skillToDelete !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setSkillToDelete(null) }}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.agent.skills.confirm_delete", "Delete Skill?")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"pages.agent.skills.confirm_delete_message",
|
||||||
|
`Are you sure you want to delete "${skillToDelete?.name}"? This action cannot be undone.`,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t("common.delete", "Delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mainContent}
|
||||||
|
{modals}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="bg-background flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader title={t("navigation.skills", "Skills")} />
|
||||||
title={t("navigation.skills")}
|
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
|
||||||
children={
|
{mainContent}
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={importInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".md,.zip,text/markdown,text/plain,application/zip,application/x-zip-compressed"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleImportFileChange}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={openImportDialog}
|
|
||||||
disabled={isImportPending}
|
|
||||||
>
|
|
||||||
{isImportPending ? (
|
|
||||||
<IconLoader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<IconPlus className="size-4" />
|
|
||||||
)}
|
|
||||||
{t("pages.agent.skills.import")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-6 py-6">
|
|
||||||
<div className="w-full max-w-6xl space-y-8">
|
|
||||||
{isLoading ? (
|
|
||||||
<PageSkeleton />
|
|
||||||
) : skillsError ? (
|
|
||||||
<div className="text-destructive py-6 text-sm">
|
|
||||||
{t("pages.agent.load_error")}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{modals}
|
||||||
<section className="animate-in fade-in space-y-3 duration-300 md:duration-500">
|
|
||||||
<Stats stats={stats} />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 py-3">
|
|
||||||
<FilterBar
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
sourceFilter={sourceFilter}
|
|
||||||
availableOrigins={availableOrigins}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
layoutMode={layoutMode}
|
|
||||||
onSearchQueryChange={setSearchQuery}
|
|
||||||
onSourceFilterChange={setSourceFilter}
|
|
||||||
onSortOrderChange={setSortOrder}
|
|
||||||
onLayoutModeChange={setLayoutMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SkillsList
|
|
||||||
sortedSkills={sortedSkills}
|
|
||||||
groupedSkills={groupedSkills}
|
|
||||||
layoutMode={layoutMode}
|
|
||||||
sourceFilter={sourceFilter}
|
|
||||||
hasActiveFilters={hasActiveFilters}
|
|
||||||
onViewSkill={handleViewSkill}
|
|
||||||
onDeleteSkill={handleRequestDelete}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DetailSheet
|
|
||||||
open={selectedSkill !== null}
|
|
||||||
selectedSkill={selectedSkill}
|
|
||||||
selectedSkillDetail={selectedSkillDetail}
|
|
||||||
isLoading={isSkillDetailLoading}
|
|
||||||
error={skillDetailError}
|
|
||||||
detailView={detailView}
|
|
||||||
onDetailViewChange={setDetailView}
|
|
||||||
onOpenChange={handleDetailSheetOpenChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImportDialog
|
|
||||||
open={isImportDialogOpen}
|
|
||||||
isImportPending={isImportPending}
|
|
||||||
isDragActive={isDragActive}
|
|
||||||
onOpenChange={handleImportDialogOpenChange}
|
|
||||||
onImportClick={handleImportClick}
|
|
||||||
onDragEnter={handleDropZoneDragEnter}
|
|
||||||
onDragLeave={handleDropZoneDragLeave}
|
|
||||||
onDrop={handleDropZoneDrop}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeleteDialog
|
|
||||||
open={skillPendingDelete !== null}
|
|
||||||
skillPendingDelete={skillPendingDelete}
|
|
||||||
isDeletePending={isDeletePending}
|
|
||||||
onOpenChange={handleDeleteDialogOpenChange}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
web/frontend/src/hooks/use-agents.ts
Normal file
77
web/frontend/src/hooks/use-agents.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { t } from "i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
listAgents,
|
||||||
|
deleteAgent,
|
||||||
|
createAgent,
|
||||||
|
updateAgent,
|
||||||
|
importAgent,
|
||||||
|
type Agent,
|
||||||
|
type AgentCreateRequest,
|
||||||
|
} from "@/api/agents"
|
||||||
|
|
||||||
|
export function useAgents() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["agents"],
|
||||||
|
queryFn: listAgents,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteAgent,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("pages.agent.agents.delete_success", "Agent deleted"))
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to delete agent")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: AgentCreateRequest) => createAgent(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("pages.agent.agents.create_success", "Agent created"))
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to create agent")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ slug, data }: { slug: string; data: Agent }) =>
|
||||||
|
updateAgent(slug, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("pages.agent.agents.update_success", "Agent updated"))
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to update agent")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: (content: string) => importAgent(content),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("pages.agent.agents.import_success", "Agent imported"))
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["agents"] })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to import agent")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
agents: listQuery.data?.agents ?? [],
|
||||||
|
isLoading: listQuery.isLoading,
|
||||||
|
isError: listQuery.isError,
|
||||||
|
deleteAgent: deleteMutation.mutate,
|
||||||
|
createAgent: createMutation.mutate,
|
||||||
|
updateAgent: updateMutation.mutate,
|
||||||
|
importAgent: importMutation.mutate,
|
||||||
|
}
|
||||||
|
}
|
||||||
36
web/frontend/src/hooks/use-cockpit-skills.ts
Normal file
36
web/frontend/src/hooks/use-cockpit-skills.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import {
|
||||||
|
listSkills,
|
||||||
|
deleteSkill,
|
||||||
|
} 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
||||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||||
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
||||||
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
||||||
|
import { Route as AgentResearchRouteImport } from './routes/agent/research'
|
||||||
import { Route as AgentHubRouteImport } from './routes/agent/hub'
|
import { Route as AgentHubRouteImport } from './routes/agent/hub'
|
||||||
import { Route as AgentCockpitRouteImport } from './routes/agent/cockpit'
|
import { Route as AgentCockpitRouteImport } from './routes/agent/cockpit'
|
||||||
|
|
||||||
|
|
@ -90,6 +91,11 @@ const AgentSkillsRoute = AgentSkillsRouteImport.update({
|
||||||
path: '/skills',
|
path: '/skills',
|
||||||
getParentRoute: () => AgentRoute,
|
getParentRoute: () => AgentRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentResearchRoute = AgentResearchRouteImport.update({
|
||||||
|
id: '/research',
|
||||||
|
path: '/research',
|
||||||
|
getParentRoute: () => AgentRoute,
|
||||||
|
} as any)
|
||||||
const AgentHubRoute = AgentHubRouteImport.update({
|
const AgentHubRoute = AgentHubRouteImport.update({
|
||||||
id: '/hub',
|
id: '/hub',
|
||||||
path: '/hub',
|
path: '/hub',
|
||||||
|
|
@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/cockpit': typeof AgentCockpitRoute
|
'/agent/cockpit': typeof AgentCockpitRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
|
'/agent/research': typeof AgentResearchRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
|
|
@ -130,6 +137,7 @@ export interface FileRoutesByTo {
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/cockpit': typeof AgentCockpitRoute
|
'/agent/cockpit': typeof AgentCockpitRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
|
'/agent/research': typeof AgentResearchRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
|
|
@ -148,6 +156,7 @@ export interface FileRoutesById {
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/cockpit': typeof AgentCockpitRoute
|
'/agent/cockpit': typeof AgentCockpitRoute
|
||||||
'/agent/hub': typeof AgentHubRoute
|
'/agent/hub': typeof AgentHubRoute
|
||||||
|
'/agent/research': typeof AgentResearchRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
|
|
@ -167,6 +176,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/cockpit'
|
| '/agent/cockpit'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
|
| '/agent/research'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
|
|
@ -184,6 +194,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/cockpit'
|
| '/agent/cockpit'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
|
| '/agent/research'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
|
|
@ -201,6 +212,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/cockpit'
|
| '/agent/cockpit'
|
||||||
| '/agent/hub'
|
| '/agent/hub'
|
||||||
|
| '/agent/research'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
|
|
@ -312,6 +324,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AgentSkillsRouteImport
|
preLoaderRoute: typeof AgentSkillsRouteImport
|
||||||
parentRoute: typeof AgentRoute
|
parentRoute: typeof AgentRoute
|
||||||
}
|
}
|
||||||
|
'/agent/research': {
|
||||||
|
id: '/agent/research'
|
||||||
|
path: '/research'
|
||||||
|
fullPath: '/agent/research'
|
||||||
|
preLoaderRoute: typeof AgentResearchRouteImport
|
||||||
|
parentRoute: typeof AgentRoute
|
||||||
|
}
|
||||||
'/agent/hub': {
|
'/agent/hub': {
|
||||||
id: '/agent/hub'
|
id: '/agent/hub'
|
||||||
path: '/hub'
|
path: '/hub'
|
||||||
|
|
@ -344,6 +363,7 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
||||||
interface AgentRouteChildren {
|
interface AgentRouteChildren {
|
||||||
AgentCockpitRoute: typeof AgentCockpitRoute
|
AgentCockpitRoute: typeof AgentCockpitRoute
|
||||||
AgentHubRoute: typeof AgentHubRoute
|
AgentHubRoute: typeof AgentHubRoute
|
||||||
|
AgentResearchRoute: typeof AgentResearchRoute
|
||||||
AgentSkillsRoute: typeof AgentSkillsRoute
|
AgentSkillsRoute: typeof AgentSkillsRoute
|
||||||
AgentToolsRoute: typeof AgentToolsRoute
|
AgentToolsRoute: typeof AgentToolsRoute
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +371,7 @@ interface AgentRouteChildren {
|
||||||
const AgentRouteChildren: AgentRouteChildren = {
|
const AgentRouteChildren: AgentRouteChildren = {
|
||||||
AgentCockpitRoute: AgentCockpitRoute,
|
AgentCockpitRoute: AgentCockpitRoute,
|
||||||
AgentHubRoute: AgentHubRoute,
|
AgentHubRoute: AgentHubRoute,
|
||||||
|
AgentResearchRoute: AgentResearchRoute,
|
||||||
AgentSkillsRoute: AgentSkillsRoute,
|
AgentSkillsRoute: AgentSkillsRoute,
|
||||||
AgentToolsRoute: AgentToolsRoute,
|
AgentToolsRoute: AgentToolsRoute,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
web/frontend/src/routes/agent/research.tsx
Normal file
13
web/frontend/src/routes/agent/research.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { ResearchPage } from "@/components/agent/research/research-page"
|
||||||
|
|
||||||
|
// Use type assertion to bypass route tree registration issue
|
||||||
|
// The route will be properly registered when routeTree is regenerated
|
||||||
|
export const Route = createFileRoute("/agent/research" as any)({
|
||||||
|
component: AgentResearchRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AgentResearchRoute() {
|
||||||
|
return <ResearchPage />
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue