picoclaw/pkg/dashboard/crud_agents.go
Edouard CLAUDE 26da5defe8 feat: add HTMX dashboard with auth and CRUD
Ref #101 — built-in web dashboard with password auth and config management.

- Optional dashboard at /dashboard (dashboard.enabled: false to disable)
- HMAC-SHA256 signed cookie sessions (24h expiry)
- Auto-generated password on first launch, saved to config.json
- HTMX + SSE real-time status, CRUD for agents/models/channels/settings
- Agent instructions editing (AGENT.md per workspace)
- 55 tests, E2E tested (login, CRUD, persistence, logout)
2026-02-21 15:32:02 +04:00

457 lines
15 KiB
Go

package dashboard
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/health"
)
func registerAgentsCRUD(srv *health.Server, cfg *config.Config, configPath string, auth func(http.HandlerFunc) http.HandlerFunc) {
srv.HandleFunc("/dashboard/fragments/agent-edit", auth(fragmentAgentEdit(cfg)))
srv.HandleFunc("/dashboard/fragments/agent-add", auth(fragmentAgentAdd()))
srv.HandleFunc("/dashboard/crud/agents/create", auth(agentCreateHandler(cfg, configPath)))
srv.HandleFunc("/dashboard/crud/agents/update", auth(agentUpdateHandler(cfg, configPath)))
srv.HandleFunc("/dashboard/crud/agents/delete", auth(agentDeleteHandler(cfg, configPath)))
srv.HandleFunc("/dashboard/fragments/defaults-edit", auth(fragmentDefaultsEdit(cfg)))
srv.HandleFunc("/dashboard/crud/agents/defaults", auth(defaultsUpdateHandler(cfg, configPath)))
}
const agentFormCSS = `<style>
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: var(--fg2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font-family: inherit; font-size: 13px; }
.form-group textarea { min-height: 120px; resize: vertical; line-height: 1.5; }
.form-group input:focus, .form-group textarea:focus { border-color: var(--blue); outline: none; }
.form-group input[type="checkbox"] { width: auto; }
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
.btn-primary { padding: 8px 16px; background: var(--blue); color: var(--bg); border: none; border-radius: 4px; cursor: pointer; font-family: inherit; }
.btn-secondary { padding: 8px 16px; background: var(--bg3); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-family: inherit; }
.success { color: #3fb950; text-align: center; padding: 16px; }
</style>`
func fragmentAgentEdit(cfg *config.Config) http.HandlerFunc {
const tmpl = `{{.CSS}}
<h3>Edit Agent</h3>
<form hx-post="/dashboard/crud/agents/update" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>ID</label>
<input type="text" name="id" value="{{.ID}}" readonly>
</div>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{.Name}}">
</div>
<div class="form-group">
<label>Model (primary)</label>
<input type="text" name="model" value="{{.Model}}" placeholder="inherited from defaults">
</div>
<div class="form-group">
<label>Skills (comma-separated)</label>
<input type="text" name="skills" value="{{.Skills}}">
</div>
<div class="form-group">
<label>Workspace</label>
<input type="text" name="workspace" value="{{.Workspace}}" placeholder="inherited from defaults">
</div>
<div class="form-group">
<label>Instructions (AGENT.md)</label>
<textarea name="instructions" placeholder="You are an expert in...">{{.Instructions}}</textarea>
</div>
<div class="form-group">
<label><input type="checkbox" name="default" value="true" {{if .Default}}checked{{end}}> Default agent</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>`
t := template.Must(template.New("agent-edit").Parse(tmpl))
return func(w http.ResponseWriter, r *http.Request) {
agentID := r.URL.Query().Get("id")
if agentID == "" {
http.Error(w, "missing id parameter", http.StatusBadRequest)
return
}
configMu.Lock()
var found *config.AgentConfig
for i := range cfg.Agents.List {
if cfg.Agents.List[i].ID == agentID {
found = &cfg.Agents.List[i]
break
}
}
configMu.Unlock()
if found == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<p>Agent %q not found</p>`, template.HTMLEscapeString(agentID))
return
}
model := ""
if found.Model != nil {
model = found.Model.Primary
}
ws := resolveAgentWorkspace(cfg, found)
instructions := readAgentInstructions(ws)
data := map[string]any{
"CSS": template.HTML(agentFormCSS),
"ID": found.ID,
"Name": found.Name,
"Model": model,
"Skills": strings.Join(found.Skills, ", "),
"Workspace": found.Workspace,
"Default": found.Default,
"Instructions": instructions,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, data)
}
}
func fragmentAgentAdd() http.HandlerFunc {
const tmpl = `{{.CSS}}
<h3>Add Agent</h3>
<form hx-post="/dashboard/crud/agents/create" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>ID (required)</label>
<input type="text" name="id" required placeholder="my-agent">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" placeholder="My Agent">
</div>
<div class="form-group">
<label>Model (primary)</label>
<input type="text" name="model" placeholder="inherited from defaults">
</div>
<div class="form-group">
<label>Skills (comma-separated)</label>
<input type="text" name="skills" placeholder="search, code">
</div>
<div class="form-group">
<label>Instructions (AGENT.md)</label>
<textarea name="instructions" placeholder="You are an expert in..."></textarea>
</div>
<div class="form-group">
<label><input type="checkbox" name="default" value="true"> Default agent</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>`
t := template.Must(template.New("agent-add").Parse(tmpl))
return func(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"CSS": template.HTML(agentFormCSS),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, data)
}
}
func fragmentDefaultsEdit(cfg *config.Config) http.HandlerFunc {
const tmpl = `{{.CSS}}
<h3>Agent Defaults</h3>
<form hx-post="/dashboard/crud/agents/defaults" hx-target="#modal-content" hx-swap="innerHTML">
<div class="form-group">
<label>Model</label>
<input type="text" name="model" value="{{.Model}}">
</div>
<div class="form-group">
<label>Max Tokens</label>
<input type="number" name="max_tokens" value="{{.MaxTokens}}">
</div>
<div class="form-group">
<label>Max Tool Iterations</label>
<input type="number" name="max_tool_iterations" value="{{.MaxToolIterations}}">
</div>
<div class="form-group">
<label>Workspace</label>
<input type="text" name="workspace" value="{{.Workspace}}">
</div>
<div class="form-group">
<label><input type="checkbox" name="restrict_to_workspace" value="true" {{if .RestrictToWorkspace}}checked{{end}}> Restrict to workspace</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>`
t := template.Must(template.New("defaults-edit").Parse(tmpl))
return func(w http.ResponseWriter, r *http.Request) {
configMu.Lock()
data := map[string]any{
"CSS": template.HTML(agentFormCSS),
"Model": cfg.Agents.Defaults.Model,
"MaxTokens": cfg.Agents.Defaults.MaxTokens,
"MaxToolIterations": cfg.Agents.Defaults.MaxToolIterations,
"Workspace": cfg.Agents.Defaults.Workspace,
"RestrictToWorkspace": cfg.Agents.Defaults.RestrictToWorkspace,
}
configMu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, data)
}
}
func agentCreateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id == "" {
jsonError(w, "id is required", http.StatusBadRequest)
return
}
configMu.Lock()
defer configMu.Unlock()
for _, a := range cfg.Agents.List {
if a.ID == id {
jsonError(w, "agent with this ID already exists", http.StatusConflict)
return
}
}
agent := config.AgentConfig{
ID: id,
Name: strings.TrimSpace(r.FormValue("name")),
Default: r.FormValue("default") == "true",
}
model := strings.TrimSpace(r.FormValue("model"))
if model != "" {
agent.Model = &config.AgentModelConfig{Primary: model}
}
agent.Skills = parseSkills(r.FormValue("skills"))
cfg.Agents.List = append(cfg.Agents.List, agent)
if err := config.SaveConfig(configPath, cfg); err != nil {
jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
return
}
if instructions := r.FormValue("instructions"); strings.TrimSpace(instructions) != "" {
ws := resolveAgentWorkspace(cfg, &agent)
if err := writeAgentInstructions(ws, instructions); err != nil {
jsonError(w, "agent created but failed to write AGENT.md: "+err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
fmt.Fprint(w, `<div class="success">Agent created successfully</div>`)
}
}
func agentUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id == "" {
jsonError(w, "id is required", http.StatusBadRequest)
return
}
configMu.Lock()
defer configMu.Unlock()
var found *config.AgentConfig
for i := range cfg.Agents.List {
if cfg.Agents.List[i].ID == id {
found = &cfg.Agents.List[i]
break
}
}
if found == nil {
jsonError(w, "agent not found", http.StatusNotFound)
return
}
found.Name = strings.TrimSpace(r.FormValue("name"))
found.Default = r.FormValue("default") == "true"
found.Workspace = strings.TrimSpace(r.FormValue("workspace"))
found.Skills = parseSkills(r.FormValue("skills"))
model := strings.TrimSpace(r.FormValue("model"))
if model != "" {
if found.Model == nil {
found.Model = &config.AgentModelConfig{}
}
found.Model.Primary = model
} else {
found.Model = nil
}
if err := config.SaveConfig(configPath, cfg); err != nil {
jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
return
}
instructions := r.FormValue("instructions")
ws := resolveAgentWorkspace(cfg, found)
if err := writeAgentInstructions(ws, instructions); err != nil {
jsonError(w, "agent updated but failed to write AGENT.md: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
fmt.Fprint(w, `<div class="success">Agent updated successfully</div>`)
}
}
func agentDeleteHandler(cfg *config.Config, configPath string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id == "" {
jsonError(w, "id is required", http.StatusBadRequest)
return
}
configMu.Lock()
defer configMu.Unlock()
idx := -1
for i := range cfg.Agents.List {
if cfg.Agents.List[i].ID == id {
idx = i
break
}
}
if idx == -1 {
jsonError(w, "agent not found", http.StatusNotFound)
return
}
cfg.Agents.List = append(cfg.Agents.List[:idx], cfg.Agents.List[idx+1:]...)
if err := config.SaveConfig(configPath, cfg); err != nil {
jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
fmt.Fprint(w, `<div class="success">Agent deleted successfully</div>`)
}
}
func defaultsUpdateHandler(cfg *config.Config, configPath string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
configMu.Lock()
defer configMu.Unlock()
cfg.Agents.Defaults.Model = strings.TrimSpace(r.FormValue("model"))
cfg.Agents.Defaults.Workspace = strings.TrimSpace(r.FormValue("workspace"))
cfg.Agents.Defaults.RestrictToWorkspace = r.FormValue("restrict_to_workspace") == "true"
if v := strings.TrimSpace(r.FormValue("max_tokens")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Agents.Defaults.MaxTokens = n
}
}
if v := strings.TrimSpace(r.FormValue("max_tool_iterations")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Agents.Defaults.MaxToolIterations = n
}
}
if err := config.SaveConfig(configPath, cfg); err != nil {
jsonError(w, "failed to save config: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Trigger", "refreshAgents, closeModal")
fmt.Fprint(w, `<div class="success">Defaults updated successfully</div>`)
}
}
func resolveAgentWorkspace(cfg *config.Config, agent *config.AgentConfig) string {
if strings.TrimSpace(agent.Workspace) != "" {
ws := strings.TrimSpace(agent.Workspace)
if strings.HasPrefix(ws, "~/") {
home, _ := os.UserHomeDir()
ws = filepath.Join(home, ws[2:])
}
return ws
}
if agent.Default || agent.ID == "" || agent.ID == "main" {
ws := cfg.Agents.Defaults.Workspace
if strings.HasPrefix(ws, "~/") {
home, _ := os.UserHomeDir()
ws = filepath.Join(home, ws[2:])
}
return ws
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "workspace-"+agent.ID)
}
func readAgentInstructions(workspace string) string {
data, err := os.ReadFile(filepath.Join(workspace, "AGENT.md"))
if err != nil {
return ""
}
return string(data)
}
func writeAgentInstructions(workspace, content string) error {
if err := os.MkdirAll(workspace, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(workspace, "AGENT.md"), []byte(content), 0o644)
}
func parseSkills(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
var skills []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
skills = append(skills, p)
}
}
return skills
}