Files
picoclaw/web/backend/api/config.go
T
wenjie dea06c391c feat(web): add agent management UI and improve launcher integration (#1358)
* Improve the web launcher and gateway integration across backend and frontend.

- add runtime model availability checks for local and OAuth-backed models
- support launcher-driven gateway host overrides and websocket URL resolution
- add gateway log clearing and keep incremental log sync consistent after resets
- migrate session history APIs to JSONL metadata-backed storage with legacy fallback
- expose session titles and improve chat history loading and error handling
- move shared backend runtime helpers into the web utils package
- avoid blocking web startup when automatic onboard initialization fails
- add backend tests covering gateway readiness, host resolution, models, logs, and sessions

* feat(agent): add skills and tools management APIs and UI

- add backend APIs to list, view, import, and delete skills
- add tool status and toggle endpoints with dependency-aware config updates
- add agent skills/tools pages, routes, sidebar entries, and i18n strings
- add backend tests for the new skills and tools flows

* chore(frontend): upgrade shadcn to 4.0.5 and refresh lockfile

* chore(web): keep backend dist placeholder tracked
2026-03-11 18:37:00 +08:00

196 lines
5.9 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/sipeed/picoclaw/pkg/config"
)
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
}
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
func (h *Handler) handleGetConfig(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
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(cfg); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// handleUpdateConfig updates the complete system configuration.
//
// PUT /api/config
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var cfg config.Config
if err := json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
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"})
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//
// PATCH /api/config
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Validate the patch is valid JSON
var patch map[string]any
if err = json.Unmarshal(patchBody, &patch); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
// Load existing config and marshal to a map for merging
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
return
}
var base map[string]any
if err = json.Unmarshal(existing, &base); err != nil {
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
return
}
// Recursively merge patch into base
mergeMap(base, patch)
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
if err != nil {
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
return
}
var newCfg config.Config
if err := json.Unmarshal(merged, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
return
}
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &newCfg); 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"})
}
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
var errs []string
// Validate model_list entries
if err := cfg.ValidateModelList(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
}
// Pico channel: token required when enabled
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
// Telegram: token required when enabled
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
// Discord: token required when enabled
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
return errs
}
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
// - If a key in src has a null value, it is deleted from dst.
// - If both dst and src have a nested object for the same key, merge recursively.
// - Otherwise the value from src overwrites dst.
func mergeMap(dst, src map[string]any) {
for key, srcVal := range src {
if srcVal == nil {
delete(dst, key)
continue
}
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
mergeMap(dstMap, srcMap)
} else {
dst[key] = srcVal
}
}
}