From dea06c391c60b6386626eba1e82889bf83f073d3 Mon Sep 17 00:00:00 2001 From: wenjie Date: Wed, 11 Mar 2026 18:37:00 +0800 Subject: [PATCH] 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 --- web/backend/api/config.go | 28 +- web/backend/api/gateway.go | 71 ++-- web/backend/api/gateway_host.go | 66 ++++ web/backend/api/gateway_host_test.go | 59 +++ web/backend/api/gateway_test.go | 274 +++++++++++++- web/backend/api/launcher_config_test.go | 2 +- web/backend/api/log.go | 8 +- web/backend/api/model_status.go | 324 ++++++++++++++++ web/backend/api/models.go | 16 +- web/backend/api/models_test.go | 313 ++++++++++++++++ web/backend/api/pico.go | 24 +- web/backend/api/router.go | 22 +- web/backend/api/session.go | 348 ++++++++++++++---- web/backend/api/session_test.go | 322 ++++++++++++++++ web/backend/api/skills.go | 331 +++++++++++++++++ web/backend/api/skills_test.go | 336 +++++++++++++++++ web/backend/api/tools.go | 323 ++++++++++++++++ web/backend/api/tools_test.go | 198 ++++++++++ web/backend/dist/.gitkeep | 1 + web/backend/main.go | 15 +- web/backend/{utils.go => utils/banner.go} | 50 +-- web/backend/utils/onboard.go | 42 +++ web/backend/utils/onboard_test.go | 101 +++++ web/backend/utils/runtime.go | 80 ++++ web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 62 ++-- web/frontend/src/api/gateway.ts | 8 + web/frontend/src/api/sessions.ts | 1 + web/frontend/src/api/skills.ts | 79 ++++ web/frontend/src/api/tools.ts | 56 +++ web/frontend/src/components/app-sidebar.tsx | 23 ++ .../src/components/chat/chat-page.tsx | 19 +- .../components/chat/session-history-menu.tsx | 15 +- .../src/components/skills/skills-page.tsx | 314 ++++++++++++++++ .../src/components/tools/tools-page.tsx | 190 ++++++++++ web/frontend/src/hooks/use-pico-chat.ts | 56 ++- web/frontend/src/hooks/use-session-history.ts | 26 +- .../src/hooks/use-sidebar-channels.ts | 2 +- web/frontend/src/i18n/locales/en.json | 103 +++++- web/frontend/src/i18n/locales/zh.json | 103 +++++- web/frontend/src/routeTree.gen.ts | 71 ++++ web/frontend/src/routes/agent.tsx | 22 ++ web/frontend/src/routes/agent/skills.tsx | 11 + web/frontend/src/routes/agent/tools.tsx | 11 + web/frontend/src/routes/logs.tsx | 65 +++- 45 files changed, 4266 insertions(+), 327 deletions(-) create mode 100644 web/backend/api/gateway_host.go create mode 100644 web/backend/api/gateway_host_test.go create mode 100644 web/backend/api/model_status.go create mode 100644 web/backend/api/models_test.go create mode 100644 web/backend/api/session_test.go create mode 100644 web/backend/api/skills.go create mode 100644 web/backend/api/skills_test.go create mode 100644 web/backend/api/tools.go create mode 100644 web/backend/api/tools_test.go rename web/backend/{utils.go => utils/banner.go} (54%) create mode 100644 web/backend/utils/onboard.go create mode 100644 web/backend/utils/onboard_test.go create mode 100644 web/backend/utils/runtime.go create mode 100644 web/frontend/src/api/skills.ts create mode 100644 web/frontend/src/api/tools.ts create mode 100644 web/frontend/src/components/skills/skills-page.tsx create mode 100644 web/frontend/src/components/tools/tools-page.tsx create mode 100644 web/frontend/src/routes/agent.tsx create mode 100644 web/frontend/src/routes/agent/skills.tsx create mode 100644 web/frontend/src/routes/agent/tools.tsx diff --git a/web/backend/api/config.go b/web/backend/api/config.go index f160b42b6..e261f43dc 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "net/http" - "os" "github.com/sipeed/picoclaw/pkg/config" ) @@ -17,36 +16,11 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) } -// loadFilteredConfig loads the configuration and filters out default placeholder credentials -// (like API limits/keys) if the configuration file has not been created yet by the user. -func (h *Handler) loadFilteredConfig() (*config.Config, error) { - cfg, err := config.LoadConfig(h.configPath) - if err != nil { - return nil, err - } - - configExists := false - if h.configPath != "" { - if _, err := os.Stat(h.configPath); err == nil { - configExists = true - } - } - - if !configExists { - for i := range cfg.ModelList { - cfg.ModelList[i].APIKey = "" - cfg.ModelList[i].AuthMethod = "" - } - } - - return cfg, nil -} - // handleGetConfig returns the complete system configuration. // // GET /api/config func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { - cfg, err := h.loadFilteredConfig() + cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 8f86dd73d..41f702e32 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -10,7 +10,6 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "runtime" "strconv" "strings" @@ -19,6 +18,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" ) // gateway holds the state for the managed gateway process. @@ -36,6 +36,7 @@ var gateway = struct { func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents) + mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart) @@ -89,11 +90,12 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } - hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" || - strings.TrimSpace(modelCfg.AuthMethod) != "" - if !hasCredential { + if !hasModelConfiguration(*modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil } + if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) { + return false, fmt.Sprintf("default model %q is not reachable", modelName), nil + } return true, "", nil } @@ -131,14 +133,18 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { func (h *Handler) startGatewayLocked() (int, error) { // Locate the picoclaw executable - execPath := findPicoclawBinary() + execPath := utils.FindPicoclawBinary() cmd := exec.Command(execPath, "gateway") + cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same // config file without requiring a --config flag on the gateway subcommand. if h.configPath != "" { - cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+h.configPath) + cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath) + } + if host := h.gatewayHostOverride(); host != "" { + cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host) } stdoutPipe, err := cmd.StdoutPipe() @@ -207,10 +213,7 @@ func (h *Handler) startGatewayLocked() (int, error) { if err != nil { continue } - healthHost := "127.0.0.1" - if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" { - healthHost = cfg.Gateway.Host - } + healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) healthPort := cfg.Gateway.Port if healthPort == 0 { healthPort = 18790 @@ -353,6 +356,20 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { h.handleGatewayStart(w, r) } +// handleGatewayClearLogs clears the in-memory gateway log buffer. +// +// POST /api/gateway/logs/clear +func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) { + gateway.logs.Clear() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "cleared", + "log_total": 0, + "log_run_id": gateway.logs.RunID(), + }) +} + // handleGatewayStatus returns the gateway run status, health info, and logs. // // GET /api/gateway/status @@ -375,9 +392,7 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { host := "127.0.0.1" port := 18790 if err == nil && cfg != nil { - if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" { - host = cfg.Gateway.Host - } + host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) if cfg.Gateway.Port != 0 { port = cfg.Gateway.Port } @@ -535,36 +550,6 @@ func (h *Handler) currentGatewayStatus() string { return string(encoded) } -// findPicoclawBinary locates the picoclaw executable. -// Search order: -// 1. PICOCLAW_BINARY environment variable (explicit override) -// 2. Same directory as the current executable -// 3. Falls back to "picoclaw" and relies on $PATH -func findPicoclawBinary() string { - binaryName := "picoclaw" - if runtime.GOOS == "windows" { - binaryName = "picoclaw.exe" - } - - // 1. Explicit override via environment variable - if p := os.Getenv("PICOCLAW_BINARY"); p != "" { - if info, _ := os.Stat(p); info != nil && !info.IsDir() { - return p - } - } - - // 2. Same directory as the launcher executable - if exe, err := os.Executable(); err == nil { - candidate := filepath.Join(filepath.Dir(exe), binaryName) - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate - } - } - - // 3. Fall back to PATH lookup - return "picoclaw" -} - // scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF. func scanPipe(r io.Reader, buf *LogBuffer) { scanner := bufio.NewScanner(r) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go new file mode 100644 index 000000000..a499c1ea2 --- /dev/null +++ b/web/backend/api/gateway_host.go @@ -0,0 +1,66 @@ +package api + +import ( + "net" + "net/http" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func (h *Handler) effectiveLauncherPublic() bool { + if h.serverPublicExplicit { + return h.serverPublic + } + + cfg, err := h.loadLauncherConfig() + if err == nil { + return cfg.Public + } + + return h.serverPublic +} + +func (h *Handler) gatewayHostOverride() string { + if h.effectiveLauncherPublic() { + return "0.0.0.0" + } + return "" +} + +func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { + if override := h.gatewayHostOverride(); override != "" { + return override + } + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.Gateway.Host) +} + +func gatewayProbeHost(bindHost string) string { + if bindHost == "" || bindHost == "0.0.0.0" { + return "127.0.0.1" + } + return bindHost +} + +func requestHostName(r *http.Request) string { + reqHost, _, err := net.SplitHostPort(r.Host) + if err == nil { + return reqHost + } + if strings.TrimSpace(r.Host) != "" { + return r.Host + } + return "127.0.0.1" +} + +func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { + host := h.effectiveGatewayBindHost(cfg) + if host == "" || host == "0.0.0.0" { + host = requestHostName(r) + } + return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" +} diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go new file mode 100644 index 000000000..afd600359 --- /dev/null +++ b/web/backend/api/gateway_host_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: false, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, true, true, nil) + + if got := h.gatewayHostOverride(); got != "0.0.0.0" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") + } +} + +func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req.Host = "192.168.1.9:18800" + + if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws") + } +} + +func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { + if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" { + t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") + } +} diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 998c133b5..84d784a5a 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -6,10 +6,13 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" ) func TestGatewayStartReady_NoDefaultModel(t *testing.T) { @@ -32,7 +35,8 @@ func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() cfg.Agents.Defaults.Model = "missing-model" - if err := config.SaveConfig(configPath, cfg); err != nil { + err := config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -54,7 +58,8 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "test-key" - if err := config.SaveConfig(configPath, cfg); err != nil { + err := config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -74,7 +79,8 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName cfg.ModelList[0].APIKey = "" cfg.ModelList[0].AuthMethod = "" - if err := config.SaveConfig(configPath, cfg); err != nil { + err := config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -91,6 +97,195 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { } } +func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://localhost:8000/v1", + }} + cfg.Agents.Defaults.ModelName = "local-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false without a running local service") + } + if !strings.Contains(reason, "not reachable") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable") + } +} + +func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }} + cfg.Agents.Defaults.ModelName = "local-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason) + } +} + +func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID) + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "remote-vllm", + Model: "vllm/custom-model", + APIBase: "https://models.example.com/v1", + APIKey: "remote-key", + }} + cfg.Agents.Defaults.ModelName = "remote-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason) + } +} + +func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOllamaModelFunc = func(apiBase, modelID string) bool { + return apiBase == "http://localhost:11434/v1" && modelID == "llama3" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "local-ollama", + Model: "ollama/llama3", + }} + cfg.Agents.Defaults.ModelName = "local-ollama" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason) + } +} + +func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "openai-oauth", + Model: "openai/gpt-5.2", + AuthMethod: "oauth", + }} + cfg.Agents.Defaults.ModelName = "openai-oauth" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false without stored credential") + } + if !strings.Contains(reason, "no credentials configured") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") + } + + err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "openai-token", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error = %v", err) + } + + ready, reason, err = h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason) + } +} + func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -122,6 +317,71 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { } } +func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.logs.Clear() + gateway.logs.Append("first line") + gateway.logs.Append("second line") + previousRunID := gateway.logs.RunID() + + clearRec := httptest.NewRecorder() + clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil) + mux.ServeHTTP(clearRec, clearReq) + + if clearRec.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK) + } + + var clearBody map[string]any + if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil { + t.Fatalf("unmarshal clear response: %v", err) + } + + if got := clearBody["status"]; got != "cleared" { + t.Fatalf("clear status body = %#v, want %q", got, "cleared") + } + + clearRunID, ok := clearBody["log_run_id"].(float64) + if !ok { + t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"]) + } + if int(clearRunID) <= previousRunID { + t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) + } + + statusRec := httptest.NewRecorder() + statusReq := httptest.NewRequest( + http.MethodGet, + "/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), + nil, + ) + mux.ServeHTTP(statusRec, statusReq) + + if statusRec.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK) + } + + var statusBody map[string]any + if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil { + t.Fatalf("unmarshal status response: %v", err) + } + + logs, ok := statusBody["logs"].([]any) + if !ok { + t.Fatalf("logs missing or not array: %#v", statusBody["logs"]) + } + if len(logs) != 0 { + t.Fatalf("logs len = %d, want 0", len(logs)) + } + if got := statusBody["log_total"]; got != float64(0) { + t.Fatalf("log_total = %#v, want 0", got) + } +} + func TestFindPicoclawBinary_EnvOverride(t *testing.T) { // Create a temporary file to act as the mock binary tmpDir := t.TempDir() @@ -132,9 +392,9 @@ func TestFindPicoclawBinary_EnvOverride(t *testing.T) { t.Setenv("PICOCLAW_BINARY", mockBinary) - got := findPicoclawBinary() + got := utils.FindPicoclawBinary() if got != mockBinary { - t.Errorf("findPicoclawBinary() = %q, want %q", got, mockBinary) + t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary) } } @@ -142,9 +402,9 @@ func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) { // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") - got := findPicoclawBinary() + got := utils.FindPicoclawBinary() // Should not return the invalid path; falls back to "picoclaw" or another found path if got == "/nonexistent/picoclaw-binary" { - t.Errorf("findPicoclawBinary() returned invalid env path %q, expected fallback", got) + t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got) } } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 5049dd88f..0d6af823c 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -14,7 +14,7 @@ import ( func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - h.SetServerOptions(19999, true, []string{"192.168.1.0/24"}) + h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"}) mux := http.NewServeMux() h.RegisterRoutes(mux) diff --git a/web/backend/api/log.go b/web/backend/api/log.go index ecf7d422f..f83f6f34c 100644 --- a/web/backend/api/log.go +++ b/web/backend/api/log.go @@ -4,7 +4,7 @@ import "sync" // LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. // It supports incremental reads via LinesSince and tracks a runID that increments -// on each Reset (used to detect gateway restarts). +// whenever the buffer is reset or cleared so clients can detect log history resets. type LogBuffer struct { mu sync.RWMutex lines []string @@ -45,6 +45,12 @@ func (b *LogBuffer) Reset() { b.runID++ } +// Clear removes all buffered lines and increments the runID so clients treat +// subsequent reads as a new log stream. +func (b *LogBuffer) Clear() { + b.Reset() +} + // LinesSince returns lines appended after the given offset, the current total count, and the runID. // If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go new file mode 100644 index 000000000..22bf5c15b --- /dev/null +++ b/web/backend/api/model_status.go @@ -0,0 +1,324 @@ +package api + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +const modelProbeTimeout = 800 * time.Millisecond + +var ( + probeTCPServiceFunc = probeTCPService + probeOllamaModelFunc = probeOllamaModel + probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel +) + +func hasModelConfiguration(m config.ModelConfig) bool { + authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) + apiKey := strings.TrimSpace(m.APIKey) + + if authMethod == "oauth" || authMethod == "token" { + if provider, ok := oauthProviderForModel(m.Model); ok { + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + } + return true + } + + if requiresRuntimeProbe(m) { + return true + } + + return apiKey != "" +} + +// isModelConfigured reports whether a model is currently available to use. +// Local models must be reachable; remote/API-key models only need saved config. +func isModelConfigured(m config.ModelConfig) bool { + if !hasModelConfiguration(m) { + return false + } + if requiresRuntimeProbe(m) { + return probeLocalModelAvailability(m) + } + return true +} + +func requiresRuntimeProbe(m config.ModelConfig) bool { + authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) + if authMethod == "local" { + return true + } + + switch modelProtocol(m.Model) { + case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": + return true + case "ollama", "vllm": + apiBase := strings.TrimSpace(m.APIBase) + return apiBase == "" || hasLocalAPIBase(apiBase) + } + + if hasLocalAPIBase(m.APIBase) { + return true + } + + return false +} + +func probeLocalModelAvailability(m config.ModelConfig) bool { + apiBase := modelProbeAPIBase(m) + protocol, modelID := splitModel(m.Model) + switch protocol { + case "ollama": + return probeOllamaModelFunc(apiBase, modelID) + case "vllm": + return probeOpenAICompatibleModelFunc(apiBase, modelID) + case "github-copilot", "copilot": + return probeTCPServiceFunc(apiBase) + case "claude-cli", "claudecli", "codex-cli", "codexcli": + return true + default: + if hasLocalAPIBase(apiBase) { + return probeOpenAICompatibleModelFunc(apiBase, modelID) + } + return false + } +} + +func modelProbeAPIBase(m config.ModelConfig) string { + if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { + return normalizeModelProbeAPIBase(apiBase) + } + + switch modelProtocol(m.Model) { + case "ollama": + return "http://localhost:11434/v1" + case "vllm": + return "http://localhost:8000/v1" + case "github-copilot", "copilot": + return "localhost:4321" + default: + return "" + } +} + +func normalizeModelProbeAPIBase(raw string) string { + u, err := parseAPIBase(raw) + if err != nil { + return strings.TrimSpace(raw) + } + + switch strings.ToLower(u.Hostname()) { + case "0.0.0.0": + u.Host = net.JoinHostPort("127.0.0.1", u.Port()) + case "::": + u.Host = net.JoinHostPort("::1", u.Port()) + default: + return strings.TrimSpace(raw) + } + + if u.Port() == "" { + u.Host = u.Hostname() + } + + return u.String() +} + +func oauthProviderForModel(model string) (string, bool) { + switch modelProtocol(model) { + case "openai": + return oauthProviderOpenAI, true + case "anthropic": + return oauthProviderAnthropic, true + case "antigravity", "google-antigravity": + return oauthProviderGoogleAntigravity, true + default: + return "", false + } +} + +func modelProtocol(model string) string { + protocol, _ := splitModel(model) + return protocol +} + +func splitModel(model string) (protocol, modelID string) { + model = strings.ToLower(strings.TrimSpace(model)) + protocol, _, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:]) +} + +func hasLocalAPIBase(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + u, err = url.Parse("//" + raw) + if err != nil { + return false + } + } + + switch strings.ToLower(u.Hostname()) { + case "localhost", "127.0.0.1", "::1", "0.0.0.0": + return true + default: + return false + } +} + +func probeTCPService(raw string) bool { + hostPort, err := hostPortFromAPIBase(raw) + if err != nil { + return false + } + + conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +func probeOllamaModel(apiBase, modelID string) bool { + root, err := apiRootFromAPIBase(apiBase) + if err != nil { + return false + } + + var resp struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"models"` + } + if err := getJSON(root+"/api/tags", &resp); err != nil { + return false + } + + for _, model := range resp.Models { + if ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) { + return true + } + } + return false +} + +func probeOpenAICompatibleModel(apiBase, modelID string) bool { + if strings.TrimSpace(apiBase) == "" { + return false + } + + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp); err != nil { + return false + } + + for _, model := range resp.Data { + if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { + return true + } + } + return false +} + +func getJSON(rawURL string, out any) error { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: modelProbeTimeout} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +func apiRootFromAPIBase(raw string) (string, error) { + u, err := parseAPIBase(raw) + if err != nil { + return "", err + } + return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil +} + +func hostPortFromAPIBase(raw string) (string, error) { + u, err := parseAPIBase(raw) + if err != nil { + return "", err + } + + if port := u.Port(); port != "" { + return u.Host, nil + } + switch strings.ToLower(u.Scheme) { + case "https": + return net.JoinHostPort(u.Hostname(), "443"), nil + default: + return net.JoinHostPort(u.Hostname(), "80"), nil + } +} + +func parseAPIBase(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty api base") + } + + u, err := url.Parse(raw) + if err == nil && u.Hostname() != "" { + return u, nil + } + + u, err = url.Parse("//" + raw) + if err != nil || u.Hostname() == "" { + return nil, fmt.Errorf("invalid api base %q", raw) + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u, nil +} + +func ollamaModelMatches(candidate, want string) bool { + candidate = strings.TrimSpace(candidate) + want = strings.TrimSpace(want) + if candidate == "" || want == "" { + return false + } + if strings.EqualFold(candidate, want) { + return true + } + + base, _, _ := strings.Cut(candidate, ":") + return strings.EqualFold(base, want) +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go index cb57d6f2e..7f3d29c77 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strconv" + "sync" "github.com/sipeed/picoclaw/pkg/config" ) @@ -45,13 +46,24 @@ type modelResponse struct { // // GET /api/models func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { - cfg, err := h.loadFilteredConfig() + cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } defaultModel := cfg.Agents.Defaults.GetModelName() + configured := make([]bool, len(cfg.ModelList)) + + var wg sync.WaitGroup + wg.Add(len(cfg.ModelList)) + for i, m := range cfg.ModelList { + go func(i int, m config.ModelConfig) { + defer wg.Done() + configured[i] = isModelConfigured(m) + }(i, m) + } + wg.Wait() models := make([]modelResponse, 0, len(cfg.ModelList)) for i, m := range cfg.ModelList { @@ -69,7 +81,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, - Configured: m.APIKey != "" || m.AuthMethod != "", + Configured: configured[i], IsDefault: m.ModelName == defaultModel, }) } diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go new file mode 100644 index 000000000..7061eb3f7 --- /dev/null +++ b/web/backend/api/models_test.go @@ -0,0 +1,313 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +func resetModelProbeHooks(t *testing.T) { + t.Helper() + + origTCPProbe := probeTCPServiceFunc + origOllamaProbe := probeOllamaModelFunc + origOpenAIProbe := probeOpenAICompatibleModelFunc + t.Cleanup(func() { + probeTCPServiceFunc = origTCPProbe + probeOllamaModelFunc = origOllamaProbe + probeOpenAICompatibleModelFunc = origOpenAIProbe + }) +} + +func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var mu sync.Mutex + var openAIProbes []string + var ollamaProbes []string + var tcpProbes []string + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + mu.Lock() + openAIProbes = append(openAIProbes, apiBase+"|"+modelID) + mu.Unlock() + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" + } + probeOllamaModelFunc = func(apiBase, modelID string) bool { + mu.Lock() + ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID) + mu.Unlock() + return apiBase == "http://localhost:11434/v1" && modelID == "llama3" + } + probeTCPServiceFunc = func(apiBase string) bool { + mu.Lock() + tcpProbes = append(tcpProbes, apiBase) + mu.Unlock() + return apiBase == "http://127.0.0.1:4321" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{ + { + ModelName: "openai-oauth", + Model: "openai/gpt-5.2", + AuthMethod: "oauth", + }, + { + ModelName: "vllm-local", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }, + { + ModelName: "ollama-default", + Model: "ollama/llama3", + }, + { + ModelName: "vllm-remote", + Model: "vllm/custom-model", + APIBase: "https://models.example.com/v1", + APIKey: "remote-key", + }, + { + ModelName: "copilot-gpt-5.2", + Model: "github-copilot/gpt-5.2", + APIBase: "http://127.0.0.1:4321", + AuthMethod: "oauth", + }, + } + cfg.Agents.Defaults.ModelName = "openai-oauth" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + got := make(map[string]bool, len(resp.Models)) + for _, model := range resp.Models { + got[model.ModelName] = model.Configured + } + + if got["openai-oauth"] { + t.Fatalf("openai oauth model configured = true, want false without stored credential") + } + if !got["vllm-local"] { + t.Fatalf("vllm local model configured = false, want true when local probe succeeds") + } + if !got["ollama-default"] { + t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") + } + if !got["vllm-remote"] { + t.Fatalf("remote vllm model configured = false, want true with api_key") + } + if !got["copilot-gpt-5.2"] { + t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") + } + if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" { + t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) + } + if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" { + t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes) + } + if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" { + t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes) + } +} + +func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "claude-oauth", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "oauth", + }} + cfg.Agents.Defaults.ModelName = "claude-oauth" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + AccessToken: "anthropic-token", + Provider: oauthProviderAnthropic, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Configured { + t.Fatalf("oauth model configured = false, want true with stored credential") + } +} + +func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + started := make(chan string, 2) + release := make(chan struct{}) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + started <- apiBase + "|" + modelID + <-release + return true + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{ + { + ModelName: "local-vllm-a", + Model: "vllm/custom-a", + APIBase: "http://127.0.0.1:8000/v1", + }, + { + ModelName: "local-vllm-b", + Model: "vllm/custom-b", + APIBase: "http://127.0.0.1:8001/v1", + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recCh := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + recCh <- rec + }() + + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected both local probes to start before the first one completed") + } + } + close(release) + + rec := <-recCh + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var gotProbe string + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + gotProbe = apiBase + "|" + modelID + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "vllm-local", + Model: "vllm/custom-model", + APIBase: "http://0.0.0.0:8000/v1", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Configured { + t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") + } + if gotProbe != "http://127.0.0.1:8000/v1|custom-model" { + t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model") + } +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index fc942d51c..a4590dcde 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -5,9 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" - "net" "net/http" - "strconv" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -30,7 +28,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := buildWsURL(r, cfg) + wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -58,7 +56,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := fmt.Sprintf("ws://%s/pico/ws", net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port))) + wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -123,7 +121,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { return } - wsURL := buildWsURL(r, cfg) + wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -134,22 +132,6 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { }) } -// buildWsURL creates a WebSocket URL for the Pico Channel. -// When the gateway host is "0.0.0.0" or empty, it uses the hostname from the -// incoming HTTP request so the browser gets a connectable address. -func buildWsURL(r *http.Request, cfg *config.Config) string { - host := cfg.Gateway.Host - if host == "" || host == "0.0.0.0" { - // Use the hostname the browser used to reach this backend - reqHost, _, err := net.SplitHostPort(r.Host) - if err != nil { - reqHost = r.Host // r.Host might not have a port - } - host = reqHost - } - return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" -} - // generateSecureToken creates a random 32-character hex string. func generateSecureToken() string { b := make([]byte, 16) diff --git a/web/backend/api/router.go b/web/backend/api/router.go index c250724d1..5f081dee9 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -9,13 +9,14 @@ import ( // Handler serves HTTP API requests. type Handler struct { - configPath string - serverPort int - serverPublic bool - serverCIDRs []string - oauthMu sync.Mutex - oauthFlows map[string]*oauthFlow - oauthState map[string]string + configPath string + serverPort int + serverPublic bool + serverPublicExplicit bool + serverCIDRs []string + oauthMu sync.Mutex + oauthFlows map[string]*oauthFlow + oauthState map[string]string } // NewHandler creates an instance of the API handler. @@ -29,9 +30,10 @@ func NewHandler(configPath string) *Handler { } // SetServerOptions stores current backend listen options for fallback behavior. -func (h *Handler) SetServerOptions(port int, public bool, allowedCIDRs []string) { +func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, allowedCIDRs []string) { h.serverPort = port h.serverPublic = public + h.serverPublicExplicit = publicExplicit h.serverCIDRs = append([]string(nil), allowedCIDRs...) } @@ -58,6 +60,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Channel catalog (for frontend navigation/config pages) h.registerChannelRoutes(mux) + // Skills and tools support/actions + h.registerSkillRoutes(mux) + h.registerToolRoutes(mux) + // OS startup / launch-at-login h.registerStartupRoutes(mux) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index e3cf674fc..42d451a05 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -1,7 +1,9 @@ package api import ( + "bufio" "encoding/json" + "errors" "net/http" "os" "path/filepath" @@ -33,12 +35,22 @@ type sessionFile struct { // sessionListItem is a lightweight summary returned by GET /api/sessions. type sessionListItem struct { ID string `json:"id"` + Title string `json:"title"` Preview string `json:"preview"` MessageCount int `json:"message_count"` Created string `json:"created"` Updated string `json:"updated"` } +type sessionMetaFile struct { + Key string `json:"key"` + Summary string `json:"summary"` + Skip int `json:"skip"` + Count int `json:"count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // picoSessionPrefix is the key prefix used by the gateway's routing for Pico // channel sessions. The full key format is: // @@ -47,7 +59,12 @@ type sessionListItem struct { // The sanitized filename replaces ':' with '_', so on disk it becomes: // // agent_main_pico_direct_pico_.json -const picoSessionPrefix = "agent:main:pico:direct:pico:" +const ( + picoSessionPrefix = "agent:main:pico:direct:pico:" + sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_" + maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB + maxSessionTitleRunes = 60 +) // extractPicoSessionID extracts the session UUID from a full session key. // Returns the UUID and true if the key matches the Pico session pattern. @@ -58,6 +75,178 @@ func extractPicoSessionID(key string) (string, bool) { return "", false } +func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) { + if strings.HasPrefix(key, sanitizedPicoSessionPrefix) { + return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true + } + return "", false +} + +func sanitizeSessionKey(key string) string { + return strings.ReplaceAll(key, ":", "_") +} + +func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) { + path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json") + data, err := os.ReadFile(path) + if err != nil { + return sessionFile{}, err + } + + var sess sessionFile + if err := json.Unmarshal(data, &sess); err != nil { + return sessionFile{}, err + } + return sess, nil +} + +func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return sessionMetaFile{Key: sessionKey}, nil + } + if err != nil { + return sessionMetaFile{}, err + } + + var meta sessionMetaFile + if err := json.Unmarshal(data, &meta); err != nil { + return sessionMetaFile{}, err + } + if meta.Key == "" { + meta.Key = sessionKey + } + return meta, nil +} + +func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + msgs := make([]providers.Message, 0) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize) + + seen := 0 + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + seen++ + if seen <= skip { + continue + } + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + msgs = append(msgs, msg) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return msgs, nil +} + +func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { + sessionKey := picoSessionPrefix + sessionID + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + jsonlPath := base + ".jsonl" + metaPath := base + ".meta.json" + + meta, err := h.readSessionMeta(metaPath, sessionKey) + if err != nil { + return sessionFile{}, err + } + + messages, err := h.readSessionMessages(jsonlPath, meta.Skip) + if err != nil { + return sessionFile{}, err + } + + updated := meta.UpdatedAt + created := meta.CreatedAt + if created.IsZero() || updated.IsZero() { + if info, statErr := os.Stat(jsonlPath); statErr == nil { + if created.IsZero() { + created = info.ModTime() + } + if updated.IsZero() { + updated = info.ModTime() + } + } + } + + return sessionFile{ + Key: meta.Key, + Messages: messages, + Summary: meta.Summary, + Created: created, + Updated: updated, + }, nil +} + +func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { + preview := "" + for _, msg := range sess.Messages { + if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { + preview = msg.Content + break + } + } + title := strings.TrimSpace(sess.Summary) + if title == "" { + title = preview + } + + title = truncateRunes(title, maxSessionTitleRunes) + preview = truncateRunes(preview, maxSessionTitleRunes) + + if preview == "" { + preview = "(empty)" + } + if title == "" { + title = preview + } + + validMessageCount := 0 + for _, msg := range sess.Messages { + if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { + validMessageCount++ + } + } + + return sessionListItem{ + ID: sessionID, + Title: title, + Preview: preview, + MessageCount: validMessageCount, + Created: sess.Created.Format(time.RFC3339), + Updated: sess.Updated.Format(time.RFC3339), + } +} + +func isEmptySession(sess sessionFile) bool { + return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == "" +} + +func truncateRunes(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(s)) + if len(runes) <= maxLen { + return string(runes) + } + return string(runes[:maxLen]) + "..." +} + // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { @@ -104,58 +293,76 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { } items := []sessionListItem{} + seen := make(map[string]struct{}) for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + if entry.IsDir() { continue } - data, err := os.ReadFile(filepath.Join(dir, entry.Name())) - if err != nil { - continue - } + name := entry.Name() + var ( + sessionID string + sess sessionFile + loadErr error + ok bool + ) - var sess sessionFile - if err := json.Unmarshal(data, &sess); err != nil { - continue - } - - // Only include Pico channel sessions - sessionID, ok := extractPicoSessionID(sess.Key) - if !ok { - continue - } - - // Build a preview from the first user message - preview := "" - for _, msg := range sess.Messages { - if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { - preview = msg.Content - break + switch { + case strings.HasSuffix(name, ".jsonl"): + sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) + if !ok { + continue } - } - if len([]rune(preview)) > 60 { - preview = string([]rune(preview)[:60]) + "..." - } - if preview == "" { - preview = "(empty)" - } - - // Only count non-empty user and assistant messages - validMessageCount := 0 - for _, msg := range sess.Messages { - if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { - validMessageCount++ + sess, loadErr = h.readJSONLSession(dir, sessionID) + if loadErr == nil && isEmptySession(sess) { + continue } + case strings.HasSuffix(name, ".meta.json"): + continue + case filepath.Ext(name) == ".json": + base := strings.TrimSuffix(name, ".json") + if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { + if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { + if jsonlSess, jsonlErr := h.readJSONLSession( + dir, + jsonlSessionID, + ); jsonlErr == nil && + !isEmptySession(jsonlSess) { + continue + } + } + } + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + if err := json.Unmarshal(data, &sess); err != nil { + continue + } + if isEmptySession(sess) { + continue + } + sessionID, ok = extractPicoSessionID(sess.Key) + if !ok { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + default: + continue } - items = append(items, sessionListItem{ - ID: sessionID, - Preview: preview, - MessageCount: validMessageCount, - Created: sess.Created.Format(time.RFC3339), - Updated: sess.Updated.Format(time.RFC3339), - }) + if loadErr != nil { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + + seen[sessionID] = struct{}{} + items = append(items, buildSessionListItem(sessionID, sess)) } // Sort by updated descending (most recent first) @@ -209,20 +416,25 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - // The sanitized filename replaces ':' with '_': - // agent:main:pico:direct:pico: -> agent_main_pico_direct_pico_.json - filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json" - - data, err := os.ReadFile(filepath.Join(dir, filename)) - if err != nil { - http.Error(w, "session not found", http.StatusNotFound) - return + sess, err := h.readJSONLSession(dir, sessionID) + if err == nil && isEmptySession(sess) { + err = os.ErrNotExist } - - var sess sessionFile - if err := json.Unmarshal(data, &sess); err != nil { - http.Error(w, "failed to parse session", http.StatusInternalServerError) - return + if err != nil { + if errors.Is(err, os.ErrNotExist) { + sess, err = h.readLegacySession(dir, sessionID) + if err == nil && isEmptySession(sess) { + err = os.ErrNotExist + } + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "session not found", http.StatusNotFound) + } else { + http.Error(w, "failed to parse session", http.StatusInternalServerError) + } + return + } } // Convert to a simpler format for the frontend @@ -268,17 +480,25 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { return } - // The sanitized filename replaces ':' with '_': - // agent:main:pico:direct:pico: -> agent_main_pico_direct_pico_.json - filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json" - filePath := filepath.Join(dir, filename) + base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) + jsonlPath := base + ".jsonl" + metaPath := base + ".meta.json" + legacyPath := base + ".json" - if err := os.Remove(filePath); err != nil { - if os.IsNotExist(err) { - http.Error(w, "session not found", http.StatusNotFound) - } else { + removed := false + for _, path := range []string{jsonlPath, metaPath, legacyPath} { + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + continue + } http.Error(w, "failed to delete session", http.StatusInternalServerError) + return } + removed = true + } + + if !removed { + http.Error(w, "session not found", http.StatusNotFound) return } diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go new file mode 100644 index 000000000..21ef5b5b8 --- /dev/null +++ b/web/backend/api/session_test.go @@ -0,0 +1,322 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" +) + +func sessionsTestDir(t *testing.T, configPath string) string { + t.Helper() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + return dir +} + +func TestHandleListSessions_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "history-jsonl" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "Explain why the history API is empty after migration.", + }); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + Content: "Because the API still reads only legacy JSON session files.", + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "tool", + Content: "ignored", + }); err != nil { + t.Fatalf("AddFullMessage(tool) error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "history-jsonl" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl") + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) + } + if items[0].Title != "JSONL-backed session" { + t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session") + } + if items[0].Preview != "Explain why the history API is empty after migration." { + t.Fatalf("items[0].Preview = %q", items[0].Preview) + } +} + +func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "summary-title" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "fallback preview", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary( + nil, + sessionKey, + " This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ", + ); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + expectedTitle := truncateRunes( + "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.", + maxSessionTitleRunes, + ) + if items[0].Title != expectedTitle { + t.Fatalf("items[0].Title = %q", items[0].Title) + } + if items[0].Preview != "fallback preview" { + t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview") + } +} + +func TestHandleGetSession_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-jsonl" + for _, msg := range []providers.Message{ + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "second"}, + {Role: "tool", Content: "ignored"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + ID string `json:"id"` + Summary string `json:"summary"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.ID != "detail-jsonl" { + t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl") + } + if resp.Summary != "detail summary" { + t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary") + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" { + t.Fatalf("first message = %#v, want user/first", resp.Messages[0]) + } + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" { + t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1]) + } +} + +func TestHandleDeleteSession_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "delete-jsonl" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "delete me", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + for _, path := range []string{base + ".jsonl", base + ".meta.json"} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, stat err = %v", path, err) + } + } +} + +func TestHandleGetSession_LegacyJSONFallback(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + manager := session.NewSessionManager(dir) + sessionKey := picoSessionPrefix + "legacy-json" + manager.AddMessage(sessionKey, "user", "legacy user") + manager.AddMessage(sessionKey, "assistant", "legacy assistant") + if err := manager.Save(sessionKey); err != nil { + t.Fatalf("Save() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl")) + if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 0 { + t.Fatalf("len(items) = %d, want 0", len(items)) + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusNotFound { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String()) + } +} diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go new file mode 100644 index 000000000..936074fee --- /dev/null +++ b/web/backend/api/skills.go @@ -0,0 +1,331 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type skillSupportResponse struct { + Skills []skills.SkillInfo `json:"skills"` +} + +type skillDetailResponse struct { + Name string `json:"name"` + Path string `json:"path"` + Source string `json:"source"` + Description string `json:"description"` + Content string `json:"content"` +} + +var ( + skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) + importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) + skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) +) + +func (h *Handler) registerSkillRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/skills", h.handleListSkills) + mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill) + mux.HandleFunc("POST /api/skills/import", h.handleImportSkill) + mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill) +} + +func (h *Handler) handleListSkills(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 + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillSupportResponse{ + Skills: loader.ListSkills(), + }) +} + +func (h *Handler) handleGetSkill(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 + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + name := r.PathValue("name") + allSkills := loader.ListSkills() + + for _, skill := range allSkills { + if skill.Name != name { + continue + } + + content, err := loadSkillContent(skill.Path) + if err != nil { + http.Error(w, "Skill content not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillDetailResponse{ + Name: skill.Name, + Path: skill.Path, + Source: skill.Source, + Description: skill.Description, + Content: content, + }) + return + } + + http.Error(w, "Skill not found", http.StatusNotFound) +} + +func (h *Handler) handleImportSkill(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 + } + + err = r.ParseMultipartForm(2 << 20) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest) + return + } + + uploadedFile, fileHeader, err := r.FormFile("file") + if err != nil { + http.Error(w, "file is required", http.StatusBadRequest) + return + } + defer uploadedFile.Close() + + content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) + return + } + if len(content) > 1<<20 { + http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) + return + } + + skillName, err := normalizeImportedSkillName(fileHeader.Filename, content) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + content = normalizeImportedSkillContent(content, skillName) + + workspace := cfg.WorkspacePath() + skillDir := filepath.Join(workspace, "skills", skillName) + skillFile := filepath.Join(skillDir, "SKILL.md") + if _, err := os.Stat(skillDir); err == nil { + http.Error(w, "skill already exists", http.StatusConflict) + return + } + + if err := os.MkdirAll(skillDir, 0o755); err != nil { + http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError) + return + } + if err := os.WriteFile(skillFile, content, 0o644); err != nil { + http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError) + return + } + + loader := newSkillsLoader(workspace) + for _, skill := range loader.ListSkills() { + if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skill) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "name": skillName, + "path": skillFile, + }) +} + +func (h *Handler) handleDeleteSkill(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 + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + name := r.PathValue("name") + for _, skill := range loader.ListSkills() { + if skill.Name != name { + continue + } + if skill.Source != "workspace" { + http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest) + return + } + if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + return + } + + http.Error(w, "Skill not found", http.StatusNotFound) +} + +func newSkillsLoader(workspace string) *skills.SkillsLoader { + return skills.NewSkillsLoader( + workspace, + filepath.Join(globalConfigDir(), "skills"), + builtinSkillsDir(), + ) +} + +func normalizeImportedSkillName(filename string, content []byte) (string, error) { + rawContent := strings.ReplaceAll(string(content), "\r\n", "\n") + rawContent = strings.ReplaceAll(rawContent, "\r", "\n") + metadata, _ := extractImportedSkillMetadata(rawContent) + + raw := strings.TrimSpace(metadata["name"]) + if raw == "" { + raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) + } + raw = strings.ToLower(raw) + raw = strings.ReplaceAll(raw, "_", "-") + raw = strings.ReplaceAll(raw, " ", "-") + raw = skillNameSanitizer.ReplaceAllString(raw, "-") + raw = strings.Trim(raw, "-") + raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-") + + if raw == "" { + return "", fmt.Errorf("skill name is required in frontmatter or filename") + } + if len(raw) > 64 { + return "", fmt.Errorf("skill name exceeds 64 characters") + } + matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw) + if err != nil || !matched { + return "", fmt.Errorf("skill name must be alphanumeric with hyphens") + } + return raw, nil +} + +func normalizeImportedSkillContent(content []byte, skillName string) []byte { + raw := strings.ReplaceAll(string(content), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + metadata, body := extractImportedSkillMetadata(raw) + description := strings.TrimSpace(metadata["description"]) + if description == "" { + description = inferImportedSkillDescription(body) + } + if description == "" { + description = "Imported skill" + } + if len(description) > 1024 { + description = strings.TrimSpace(description[:1024]) + } + + body = strings.TrimLeft(body, "\n") + var builder strings.Builder + builder.WriteString("---\n") + builder.WriteString("name: ") + builder.WriteString(skillName) + builder.WriteString("\n") + builder.WriteString("description: ") + builder.WriteString(description) + builder.WriteString("\n") + builder.WriteString("---\n\n") + builder.WriteString(body) + if !strings.HasSuffix(builder.String(), "\n") { + builder.WriteString("\n") + } + return []byte(builder.String()) +} + +func extractImportedSkillMetadata(raw string) (map[string]string, string) { + matches := importedSkillFrontmatter.FindStringSubmatch(raw) + if len(matches) != 2 { + return map[string]string{}, raw + } + meta := parseImportedSkillYAML(matches[1]) + body := importedSkillFrontmatter.ReplaceAllString(raw, "") + return meta, body +} + +func parseImportedSkillYAML(frontmatter string) map[string]string { + result := make(map[string]string) + for _, line := range strings.Split(frontmatter, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) + } + return result +} + +func inferImportedSkillDescription(body string) string { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + line = strings.TrimLeft(line, "#-*0123456789. ") + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func loadSkillContent(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", err + } + return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil +} + +func globalConfigDir() string { + if home := os.Getenv("PICOCLAW_HOME"); home != "" { + return home + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".picoclaw") +} + +func builtinSkillsDir() string { + if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" { + return path + } + wd, err := os.Getwd() + if err != nil { + return "" + } + return filepath.Join(wd, "skills") +} diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go new file mode 100644 index 000000000..3289d5b33 --- /dev/null +++ b/web/backend/api/skills_test.go @@ -0,0 +1,336 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleListSkills(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil { + t.Fatalf("MkdirAll(workspace skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"), + []byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(workspace skill) error = %v", err) + } + + globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill") + if err := os.MkdirAll(globalSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(global skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(globalSkillDir, "SKILL.md"), + []byte("---\nname: global-skill\ndescription: Global skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(global skill) error = %v", err) + } + + builtinRoot := filepath.Join(t.TempDir(), "builtin-skills") + oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS") + if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil { + t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err) + } + defer func() { + if oldBuiltin == "" { + _ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS") + } else { + _ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin) + } + }() + + builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill") + if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(builtin skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(builtinSkillDir, "SKILL.md"), + []byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(builtin skill) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Skills) != 3 { + t.Fatalf("skills count = %d, want 3", len(resp.Skills)) + } + + gotSkills := make(map[string]string, len(resp.Skills)) + for _, skill := range resp.Skills { + gotSkills[skill.Name] = skill.Source + } + if gotSkills["workspace-skill"] != "workspace" { + t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"]) + } + if gotSkills["global-skill"] != "global" { + t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"]) + } + if gotSkills["builtin-skill"] != "builtin" { + t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"]) + } +} + +func TestHandleGetSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "viewer-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte( + "---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n", + ), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" { + t.Fatalf("unexpected response: %#v", resp) + } + if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" { + t.Fatalf("content = %q", resp.Content) + } +} + +func TestHandleGetSkillUsesResolvedPath(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "folder-name") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Name != "display-name" { + t.Fatalf("resp.Name = %q, want display-name", resp.Name) + } + if resp.Content != "# Display Name\n" { + t.Fatalf("content = %q", resp.Content) + } +} + +func TestHandleImportSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "Plain Skill.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + _, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n") + if err != nil { + t.Fatalf("WriteString() error = %v", err) + } + err = writer.Close() + if err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md") + content, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n" + if string(content) != expected { + t.Fatalf("saved skill content mismatch:\n%s", string(content)) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil) + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + var listResp skillSupportResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal list response error = %v", err) + } + found := false + for _, skill := range listResp.Skills { + if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" { + found = true + } + } + if !found { + t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills) + } +} + +func TestHandleDeleteSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "delete-me") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: delete-me\ndescription: delete me\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed, stat err=%v", err) + } +} diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go new file mode 100644 index 000000000..373a3be12 --- /dev/null +++ b/web/backend/api/tools.go @@ -0,0 +1,323 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type toolCatalogEntry struct { + Name string + Description string + Category string + ConfigKey string +} + +type toolSupportItem struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + ConfigKey string `json:"config_key"` + Status string `json:"status"` + ReasonCode string `json:"reason_code,omitempty"` +} + +type toolSupportResponse struct { + Tools []toolSupportItem `json:"tools"` +} + +type toolStateRequest struct { + Enabled bool `json:"enabled"` +} + +var toolCatalog = []toolCatalogEntry{ + { + Name: "read_file", + Description: "Read file content from the workspace or explicitly allowed paths.", + Category: "filesystem", + ConfigKey: "read_file", + }, + { + Name: "write_file", + Description: "Create or overwrite files within the writable workspace scope.", + Category: "filesystem", + ConfigKey: "write_file", + }, + { + Name: "list_dir", + Description: "Inspect directories and enumerate files available to the agent.", + Category: "filesystem", + ConfigKey: "list_dir", + }, + { + Name: "edit_file", + Description: "Apply targeted edits to existing files without rewriting everything.", + Category: "filesystem", + ConfigKey: "edit_file", + }, + { + Name: "append_file", + Description: "Append content to the end of an existing file.", + Category: "filesystem", + ConfigKey: "append_file", + }, + { + Name: "exec", + Description: "Run shell commands inside the configured workspace sandbox.", + Category: "filesystem", + ConfigKey: "exec", + }, + { + Name: "cron", + Description: "Schedule one-time or recurring reminders, jobs, and shell commands.", + Category: "automation", + ConfigKey: "cron", + }, + { + Name: "web_search", + Description: "Search the web using the configured providers.", + Category: "web", + ConfigKey: "web", + }, + { + Name: "web_fetch", + Description: "Fetch and summarize the contents of a webpage.", + Category: "web", + ConfigKey: "web_fetch", + }, + { + Name: "message", + Description: "Send a follow-up message back to the active user or chat.", + Category: "communication", + ConfigKey: "message", + }, + { + Name: "send_file", + Description: "Send an outbound file or media attachment to the active chat.", + Category: "communication", + ConfigKey: "send_file", + }, + { + Name: "find_skills", + Description: "Search external skill registries for installable skills.", + Category: "skills", + ConfigKey: "find_skills", + }, + { + Name: "install_skill", + Description: "Install a skill into the current workspace from a registry.", + Category: "skills", + ConfigKey: "install_skill", + }, + { + Name: "spawn", + Description: "Launch a background subagent for long-running or delegated work.", + Category: "agents", + ConfigKey: "spawn", + }, + { + Name: "i2c", + Description: "Interact with I2C hardware devices exposed on the host.", + Category: "hardware", + ConfigKey: "i2c", + }, + { + Name: "spi", + Description: "Interact with SPI hardware devices exposed on the host.", + Category: "hardware", + ConfigKey: "spi", + }, + { + Name: "tool_search_tool_regex", + Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", + Category: "discovery", + ConfigKey: "mcp.discovery.use_regex", + }, + { + Name: "tool_search_tool_bm25", + Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.", + Category: "discovery", + ConfigKey: "mcp.discovery.use_bm25", + }, +} + +func (h *Handler) registerToolRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/tools", h.handleListTools) + mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState) +} + +func (h *Handler) handleListTools(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") + json.NewEncoder(w).Encode(toolSupportResponse{ + Tools: buildToolSupport(cfg), + }) +} + +func (h *Handler) handleUpdateToolState(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 + } + + var req toolStateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + 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"}) +} + +func buildToolSupport(cfg *config.Config) []toolSupportItem { + items := make([]toolSupportItem, 0, len(toolCatalog)) + for _, entry := range toolCatalog { + status := "disabled" + reasonCode := "" + + switch entry.Name { + case "find_skills", "install_skill": + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + if cfg.Tools.IsToolEnabled("skills") { + status = "enabled" + } else { + status = "blocked" + reasonCode = "requires_skills" + } + } + case "spawn": + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + if cfg.Tools.IsToolEnabled("subagent") { + status = "enabled" + } else { + status = "blocked" + reasonCode = "requires_subagent" + } + } + case "tool_search_tool_regex": + status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex) + case "tool_search_tool_bm25": + status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25) + case "i2c", "spi": + status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) + default: + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + status = "enabled" + } + } + + items = append(items, toolSupportItem{ + Name: entry.Name, + Description: entry.Description, + Category: entry.Category, + ConfigKey: entry.ConfigKey, + Status: status, + ReasonCode: reasonCode, + }) + } + return items +} + +func resolveHardwareToolSupport(enabled bool) (string, string) { + if !enabled { + return "disabled", "" + } + if runtime.GOOS != "linux" { + return "blocked", "requires_linux" + } + return "enabled", "" +} + +func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) { + if !cfg.Tools.IsToolEnabled("mcp") { + return "disabled", "" + } + if !cfg.Tools.MCP.Discovery.Enabled { + return "blocked", "requires_mcp_discovery" + } + if !methodEnabled { + return "disabled", "" + } + return "enabled", "" +} + +func applyToolState(cfg *config.Config, toolName string, enabled bool) error { + switch toolName { + case "read_file": + cfg.Tools.ReadFile.Enabled = enabled + case "write_file": + cfg.Tools.WriteFile.Enabled = enabled + case "list_dir": + cfg.Tools.ListDir.Enabled = enabled + case "edit_file": + cfg.Tools.EditFile.Enabled = enabled + case "append_file": + cfg.Tools.AppendFile.Enabled = enabled + case "exec": + cfg.Tools.Exec.Enabled = enabled + case "cron": + cfg.Tools.Cron.Enabled = enabled + case "web_search": + cfg.Tools.Web.Enabled = enabled + case "web_fetch": + cfg.Tools.WebFetch.Enabled = enabled + case "message": + cfg.Tools.Message.Enabled = enabled + case "send_file": + cfg.Tools.SendFile.Enabled = enabled + case "find_skills": + cfg.Tools.FindSkills.Enabled = enabled + if enabled { + cfg.Tools.Skills.Enabled = true + } + case "install_skill": + cfg.Tools.InstallSkill.Enabled = enabled + if enabled { + cfg.Tools.Skills.Enabled = true + } + case "spawn": + cfg.Tools.Spawn.Enabled = enabled + if enabled { + cfg.Tools.Subagent.Enabled = true + } + case "i2c": + cfg.Tools.I2C.Enabled = enabled + case "spi": + cfg.Tools.SPI.Enabled = enabled + case "tool_search_tool_regex": + cfg.Tools.MCP.Discovery.UseRegex = enabled + if enabled { + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + } + case "tool_search_tool_bm25": + cfg.Tools.MCP.Discovery.UseBM25 = enabled + if enabled { + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + } + default: + return fmt.Errorf("tool %q cannot be updated", toolName) + } + return nil +} diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go new file mode 100644 index 000000000..646cefbe2 --- /dev/null +++ b/web/backend/api/tools_test.go @@ -0,0 +1,198 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleListTools(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.WriteFile.Enabled = false + cfg.Tools.Cron.Enabled = true + cfg.Tools.FindSkills.Enabled = true + cfg.Tools.Skills.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = false + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + cfg.Tools.MCP.Discovery.UseRegex = true + cfg.Tools.MCP.Discovery.UseBM25 = false + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp toolSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools := make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + if gotTools["read_file"].Status != "enabled" { + t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status) + } + if gotTools["write_file"].Status != "disabled" { + t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status) + } + if gotTools["cron"].Status != "enabled" { + t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status) + } + if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" { + t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"]) + } + if gotTools["find_skills"].Status != "enabled" { + t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status) + } + if gotTools["tool_search_tool_regex"].Status != "enabled" { + t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status) + } + if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" { + t.Fatalf( + "tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex", + gotTools["tool_search_tool_regex"].ConfigKey, + ) + } + if gotTools["tool_search_tool_bm25"].Status != "disabled" { + t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status) + } + if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" { + t.Fatalf( + "tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25", + gotTools["tool_search_tool_bm25"].ConfigKey, + ) + } + if runtime.GOOS == "linux" { + if gotTools["i2c"].Status != "disabled" { + t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status) + } + } else { + cfg.Tools.I2C.Enabled = true + cfg.Tools.SPI.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools = make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + + if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" { + t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"]) + } + if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" { + t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"]) + } + } +} + +func TestHandleUpdateToolState(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Spawn.Enabled = false + cfg.Tools.Subagent.Enabled = false + cfg.Tools.Cron.Enabled = false + cfg.Tools.MCP.Enabled = false + cfg.Tools.MCP.Discovery.Enabled = false + cfg.Tools.MCP.Discovery.UseRegex = false + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/tools/spawn/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest( + http.MethodPut, + "/api/tools/tool_search_tool_regex/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req2.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest( + http.MethodPut, + "/api/tools/cron/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req3.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated) error = %v", err) + } + if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled { + t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools) + } + if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex { + t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP) + } + if !updated.Tools.Cron.Enabled { + t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron) + } +} diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep index e69de29bb..4b533f03a 100644 --- a/web/backend/dist/.gitkeep +++ b/web/backend/dist/.gitkeep @@ -0,0 +1 @@ +# Keep the embedded web backend dist directory in version control. diff --git a/web/backend/main.go b/web/backend/main.go index b8c4dc2bb..650540ea8 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -25,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" + "github.com/sipeed/picoclaw/web/backend/utils" ) func main() { @@ -51,7 +52,7 @@ func main() { flag.Parse() // Resolve config path - configPath := getDefaultConfigPath() + configPath := utils.GetDefaultConfigPath() if flag.NArg() > 0 { configPath = flag.Arg(0) } @@ -60,6 +61,10 @@ func main() { if err != nil { log.Fatalf("Failed to resolve config path: %v", err) } + err = utils.EnsureOnboarded(absPath) + if err != nil { + log.Printf("Warning: Failed to initialize PicoClaw config automatically: %v", err) + } var explicitPort bool var explicitPublic bool @@ -109,7 +114,7 @@ func main() { // API Routes (e.g. /api/status) apiHandler := api.NewHandler(absPath) - apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs) + apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets @@ -128,13 +133,13 @@ func main() { ) // Print startup banner - fmt.Print(banner) + fmt.Print(utils.Banner) fmt.Println() fmt.Println(" Open the following URL in your browser:") fmt.Println() fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) if effectivePublic { - if ip := getLocalIP(); ip != "" { + if ip := utils.GetLocalIP(); ip != "" { fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) } } @@ -145,7 +150,7 @@ func main() { go func() { time.Sleep(500 * time.Millisecond) url := "http://localhost:" + effectivePort - if err := openBrowser(url); err != nil { + if err := utils.OpenBrowser(url); err != nil { log.Printf("Warning: Failed to auto-open browser: %v", err) } }() diff --git a/web/backend/utils.go b/web/backend/utils/banner.go similarity index 54% rename from web/backend/utils.go rename to web/backend/utils/banner.go index 6fa734aeb..a64ea6390 100644 --- a/web/backend/utils.go +++ b/web/backend/utils/banner.go @@ -1,19 +1,10 @@ -package main - -import ( - "fmt" - "net" - "os" - "os/exec" - "path/filepath" - "runtime" -) +package utils const ( colorBlue = "\x1b[38;2;62;93;185m" colorRed = "\x1b[38;2;213;70;70m" colorReset = "\x1b[0m" - banner = "\r\n" + + Banner = "\r\n" + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + @@ -22,40 +13,3 @@ const ( colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" + colorReset ) - -// getDefaultConfigPath returns the default path to the picoclaw config file. -func getDefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "config.json" - } - return filepath.Join(home, ".picoclaw", "config.json") -} - -// getLocalIP returns the local IP address of the machine. -func getLocalIP() string { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "" - } - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String() - } - } - return "" -} - -// openBrowser automatically opens the given URL in the default browser. -func openBrowser(url string) error { - switch runtime.GOOS { - case "linux": - return exec.Command("xdg-open", url).Start() - case "windows": - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - return exec.Command("open", url).Start() - default: - return fmt.Errorf("unsupported platform") - } -} diff --git a/web/backend/utils/onboard.go b/web/backend/utils/onboard.go new file mode 100644 index 000000000..fbe34f220 --- /dev/null +++ b/web/backend/utils/onboard.go @@ -0,0 +1,42 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +var execCommand = exec.Command + +func EnsureOnboarded(configPath string) error { + _, err := os.Stat(configPath) + if err == nil { + return nil + } + if !os.IsNotExist(err) { + return fmt.Errorf("stat config: %w", err) + } + + cmd := execCommand(FindPicoclawBinary(), "onboard") + cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+configPath) + cmd.Stdin = strings.NewReader("n\n") + + output, err := cmd.CombinedOutput() + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("run onboard: %w", err) + } + return fmt.Errorf("run onboard: %w: %s", err, trimmed) + } + + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("onboard completed but did not create config %s", configPath) + } + return fmt.Errorf("verify config after onboard: %w", err) + } + + return nil +} diff --git a/web/backend/utils/onboard_test.go b/web/backend/utils/onboard_test.go new file mode 100644 index 000000000..06f967e76 --- /dev/null +++ b/web/backend/utils/onboard_test.go @@ -0,0 +1,101 @@ +package utils + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + called := false + execCommand = func(name string, args ...string) *exec.Cmd { + called = true + return exec.Command("sh", "-c", "exit 1") + } + + if err := EnsureOnboarded(configPath); err != nil { + t.Fatalf("EnsureOnboarded() error = %v", err) + } + if called { + t.Fatal("expected onboard command not to run when config already exists") + } +} + +func TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("EXPECTED_CONFIG_PATH", configPath) + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + var gotName string + var gotArgs []string + execCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command( + "sh", + "-c", + `test "$PICOCLAW_CONFIG" = "$EXPECTED_CONFIG_PATH" && +mkdir -p "$(dirname "$PICOCLAW_CONFIG")" && +printf '{}' > "$PICOCLAW_CONFIG"`, + ) + } + + if err := EnsureOnboarded(configPath); err != nil { + t.Fatalf("EnsureOnboarded() error = %v", err) + } + if gotName == "" { + t.Fatal("expected onboard command to run") + } + if len(gotArgs) != 1 || gotArgs[0] != "onboard" { + t.Fatalf("command args = %#v, want []string{\"onboard\"}", gotArgs) + } + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected config to be created: %v", err) + } +} + +func TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "exit 0") + } + + if err := EnsureOnboarded(configPath); err == nil { + t.Fatal("EnsureOnboarded() error = nil, want failure when onboard does not create config") + } +} + +func TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "echo onboarding failed >&2; exit 2") + } + + err := EnsureOnboarded(configPath) + if err == nil { + t.Fatal("EnsureOnboarded() error = nil, want failure") + } + if !strings.Contains(err.Error(), "onboarding failed") { + t.Fatalf("error = %q, want onboard output included", err) + } +} diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go new file mode 100644 index 000000000..4e6c32c56 --- /dev/null +++ b/web/backend/utils/runtime.go @@ -0,0 +1,80 @@ +package utils + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// GetDefaultConfigPath returns the default path to the picoclaw config file. +func GetDefaultConfigPath() string { + if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { + return configPath + } + if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { + return filepath.Join(picoclawHome, "config.json") + } + home, err := os.UserHomeDir() + if err != nil { + return "config.json" + } + return filepath.Join(home, ".picoclaw", "config.json") +} + +// FindPicoclawBinary locates the picoclaw executable. +// Search order: +// 1. PICOCLAW_BINARY environment variable (explicit override) +// 2. Same directory as the current executable +// 3. Falls back to "picoclaw" and relies on $PATH +func FindPicoclawBinary() string { + binaryName := "picoclaw" + if runtime.GOOS == "windows" { + binaryName = "picoclaw.exe" + } + + if p := os.Getenv("PICOCLAW_BINARY"); p != "" { + if info, _ := os.Stat(p); info != nil && !info.IsDir() { + return p + } + } + + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), binaryName) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + + return "picoclaw" +} + +// GetLocalIP returns the local IP address of the machine. +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + return "" +} + +// OpenBrowser automatically opens the given URL in the default browser. +func OpenBrowser(url string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json index ee46cdcda..687fd5771 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -32,7 +32,7 @@ "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", - "shadcn": "^3.8.5", + "shadcn": "^4.0.5", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 8e89cbbe5..08f13a0b9 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@hono/node-server': 1.19.11 + express-rate-limit: 8.3.1 + hono: 4.12.7 + importers: .: @@ -512,11 +517,11 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.7 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1359,79 +1364,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1516,28 +1508,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -2296,8 +2284,8 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2496,8 +2484,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.3: - resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2559,8 +2547,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2785,28 +2773,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -4332,9 +4316,9 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.9(hono@4.12.3)': + '@hono/node-server@1.19.11(hono@4.12.7)': dependencies: - hono: 4.12.3 + hono: 4.12.7 '@humanfs/core@0.19.1': {} @@ -4396,7 +4380,7 @@ snapshots: '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.12.3) + '@hono/node-server': 1.19.11(hono@4.12.7) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4405,8 +4389,8 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.3 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -6146,10 +6130,10 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.1.0 express@5.2.1: dependencies: @@ -6374,7 +6358,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.3: {} + hono@4.12.7: {} html-parse-stringify@3.0.1: dependencies: @@ -6430,7 +6414,7 @@ snapshots: inline-style-parser@0.2.7: {} - ip-address@10.0.1: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 5a58d48f0..020e92e3a 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -14,6 +14,8 @@ interface GatewayStatusResponse { interface GatewayActionResponse { status: string pid?: number + log_total?: number + log_run_id?: number } const BASE_URL = "" @@ -59,4 +61,10 @@ export async function restartGateway(): Promise { }) } +export async function clearGatewayLogs(): Promise { + return request("/api/gateway/logs/clear", { + method: "POST", + }) +} + export type { GatewayStatusResponse, GatewayActionResponse } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index 56ef148db..10b0d28fd 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -2,6 +2,7 @@ export interface SessionSummary { id: string + title: string preview: string message_count: number created: string diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts new file mode 100644 index 000000000..307cbd788 --- /dev/null +++ b/web/frontend/src/api/skills.ts @@ -0,0 +1,79 @@ +export interface SkillSupportItem { + name: string + path: string + source: "workspace" | "global" | "builtin" | string + description: string +} + +export interface SkillDetailResponse extends SkillSupportItem { + content: string +} + +interface SkillsResponse { + skills: SkillSupportItem[] +} + +interface SkillActionResponse { + status?: string + name?: string + path?: string + source?: string + description?: string +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(path, options) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() as Promise +} + +export async function getSkills(): Promise { + return request("/api/skills") +} + +export async function getSkill(name: string): Promise { + return request(`/api/skills/${encodeURIComponent(name)}`) +} + +export async function importSkill(file: File): Promise { + const formData = new FormData() + formData.set("file", file) + + const res = await fetch("/api/skills/import", { + method: "POST", + body: formData, + }) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() as Promise +} + +export async function deleteSkill(name: string): Promise { + return request( + `/api/skills/${encodeURIComponent(name)}`, + { + method: "DELETE", + }, + ) +} + +async function extractErrorMessage(res: Response): Promise { + try { + const body = (await res.json()) 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 { + // ignore invalid body + } + return `API error: ${res.status} ${res.statusText}` +} diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts new file mode 100644 index 000000000..9f09efbfd --- /dev/null +++ b/web/frontend/src/api/tools.ts @@ -0,0 +1,56 @@ +export interface ToolSupportItem { + name: string + description: string + category: string + config_key: string + status: "enabled" | "disabled" | "blocked" + reason_code?: string +} + +interface ToolsResponse { + tools: ToolSupportItem[] +} + +interface ToolActionResponse { + status: string +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(path, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // ignore invalid body + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getTools(): Promise { + return request("/api/tools") +} + +export async function setToolEnabled( + name: string, + enabled: boolean, +): Promise { + return request( + `/api/tools/${encodeURIComponent(name)}/state`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ) +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index dc24f8781..702212857 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -7,6 +7,8 @@ import { IconListDetails, IconMessageCircle, IconSettings, + IconSparkles, + IconTools, } from "@tabler/icons-react" import { Link, useRouterState } from "@tanstack/react-router" import * as React from "react" @@ -53,6 +55,10 @@ const baseNavGroups: Omit[] = [ label: "navigation.model_group", defaultOpen: true, }, + { + label: "navigation.agent_group", + defaultOpen: true, + }, { label: "navigation.services", defaultOpen: true, @@ -113,6 +119,23 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, { ...baseNavGroups[2], + items: [ + { + title: "navigation.skills", + url: "/agent/skills", + icon: IconSparkles, + translateTitle: true, + }, + { + title: "navigation.tools", + url: "/agent/tools", + icon: IconTools, + translateTitle: true, + }, + ], + }, + { + ...baseNavGroups[3], items: [ { title: "navigation.config", diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 0fd23a6a5..a3ab843b4 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -43,11 +43,18 @@ export function ChatPage() { handleSetDefault, } = useChatModels({ isConnected }) - const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } = - useSessionHistory({ - activeSessionId, - onDeletedActiveSession: newChat, - }) + const { + sessions, + hasMore, + loadError, + loadErrorMessage, + observerRef, + loadSessions, + handleDeleteSession, + } = useSessionHistory({ + activeSessionId, + onDeletedActiveSession: newChat, + }) const handleScroll = (e: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = e.currentTarget @@ -96,6 +103,8 @@ export function ChatPage() { sessions={sessions} activeSessionId={activeSessionId} hasMore={hasMore} + loadError={loadError} + loadErrorMessage={loadErrorMessage} observerRef={observerRef} onOpenChange={(open) => { if (open) { diff --git a/web/frontend/src/components/chat/session-history-menu.tsx b/web/frontend/src/components/chat/session-history-menu.tsx index f2e93295c..3f293e353 100644 --- a/web/frontend/src/components/chat/session-history-menu.tsx +++ b/web/frontend/src/components/chat/session-history-menu.tsx @@ -17,6 +17,8 @@ interface SessionHistoryMenuProps { sessions: SessionSummary[] activeSessionId: string hasMore: boolean + loadError: boolean + loadErrorMessage: string observerRef: RefObject onOpenChange: (open: boolean) => void onSwitchSession: (sessionId: string) => void @@ -27,6 +29,8 @@ export function SessionHistoryMenu({ sessions, activeSessionId, hasMore, + loadError, + loadErrorMessage, observerRef, onOpenChange, onSwitchSession, @@ -44,7 +48,14 @@ export function SessionHistoryMenu({ - {sessions.length === 0 ? ( + {loadError && ( + + + {loadErrorMessage} + + + )} + {sessions.length === 0 && !loadError ? ( {t("chat.noHistory")} @@ -60,7 +71,7 @@ export function SessionHistoryMenu({ onClick={() => onSwitchSession(session.id)} > - {session.preview} + {session.title || session.preview} {t("chat.messagesCount", { diff --git a/web/frontend/src/components/skills/skills-page.tsx b/web/frontend/src/components/skills/skills-page.tsx new file mode 100644 index 000000000..3b5c5acb4 --- /dev/null +++ b/web/frontend/src/components/skills/skills-page.tsx @@ -0,0 +1,314 @@ +import { + IconFileInfo, + IconLoader2, + IconPlus, + IconTrash, +} from "@tabler/icons-react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { type ChangeEvent, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { toast } from "sonner" + +import { + type SkillSupportItem, + deleteSkill, + getSkill, + getSkills, + importSkill, +} from "@/api/skills" +import { PageHeader } from "@/components/page-header" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +export function SkillsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const importInputRef = useRef(null) + const [selectedSkill, setSelectedSkill] = useState( + null, + ) + const [skillPendingDelete, setSkillPendingDelete] = + useState(null) + + const { data, isLoading, error } = useQuery({ + queryKey: ["skills"], + queryFn: getSkills, + }) + const { + data: selectedSkillDetail, + isLoading: isSkillDetailLoading, + error: skillDetailError, + } = useQuery({ + queryKey: ["skills", selectedSkill?.name], + queryFn: () => getSkill(selectedSkill!.name), + enabled: selectedSkill !== null, + }) + + const importMutation = useMutation({ + mutationFn: async (file: File) => importSkill(file), + onSuccess: () => { + toast.success(t("pages.agent.skills.import_success")) + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.import_error"), + ) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: async (name: string) => deleteSkill(name), + onSuccess: (_, deletedName) => { + toast.success(t("pages.agent.skills.delete_success")) + setSkillPendingDelete(null) + if ( + selectedSkill?.name === deletedName && + selectedSkill.source === "workspace" + ) { + setSelectedSkill(null) + } + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.delete_error"), + ) + }, + }) + + const handleImportClick = () => { + importInputRef.current?.click() + } + + const handleImportFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + importMutation.mutate(file) + event.target.value = "" + } + + return ( +
+ + + + + } + /> + +
+
+ {isLoading ? ( +
+ {t("labels.loading")} +
+ ) : error ? ( +
+ {t("pages.agent.load_error")} +
+ ) : ( +
+

+ {t("pages.agent.skills.description")} +

+ + {data?.skills.length ? ( +
+ {data.skills.map((skill) => ( + + +
+
+ + {skill.name} + + + {skill.description || + t("pages.agent.skills.no_description")} + +
+
+ + {skill.source === "workspace" ? ( + + ) : null} +
+
+
+ +
+ {t("pages.agent.skills.path")} +
+
+ {skill.path} +
+
+
+ ))} +
+ ) : ( + + + {t("pages.agent.skills.empty")} + + + )} +
+ )} +
+
+ + { + if (!open) setSelectedSkill(null) + }} + > + + + + {selectedSkill?.name || t("pages.agent.skills.viewer_title")} + + + {selectedSkill?.description || + t("pages.agent.skills.viewer_description")} + + + +
+ {isSkillDetailLoading ? ( +
+ {t("pages.agent.skills.loading_detail")} +
+ ) : skillDetailError ? ( +
+ {t("pages.agent.skills.load_detail_error")} +
+ ) : selectedSkillDetail ? ( +
+
+ + {selectedSkillDetail.content} + +
+
+ ) : null} +
+
+
+ + { + if (!open) setSkillPendingDelete(null) + }} + > + + + + {t("pages.agent.skills.delete_title")} + + + {t("pages.agent.skills.delete_description", { + name: skillPendingDelete?.name, + })} + + + + + {t("common.cancel")} + + { + if (skillPendingDelete) + deleteMutation.mutate(skillPendingDelete.name) + }} + > + {deleteMutation.isPending ? ( + + ) : ( + + )} + {t("pages.agent.skills.delete_confirm")} + + + + +
+ ) +} diff --git a/web/frontend/src/components/tools/tools-page.tsx b/web/frontend/src/components/tools/tools-page.tsx new file mode 100644 index 000000000..05aa42122 --- /dev/null +++ b/web/frontend/src/components/tools/tools-page.tsx @@ -0,0 +1,190 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { cn } from "@/lib/utils" + +export function ToolsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data, isLoading, error } = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + + const toggleMutation = useMutation({ + mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => + setToolEnabled(name, enabled), + onSuccess: (_, variables) => { + toast.success( + variables.enabled + ? t("pages.agent.tools.enable_success") + : t("pages.agent.tools.disable_success"), + ) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.tools.toggle_error"), + ) + }, + }) + + const groupedTools = (() => { + if (!data) return [] as Array<[string, ToolSupportItem[]]> + const buckets = new Map() + for (const item of data.tools) { + const list = buckets.get(item.category) ?? [] + list.push(item) + buckets.set(item.category, list) + } + return Array.from(buckets.entries()) + })() + + return ( +
+ + +
+
+ {isLoading ? ( +
+ {t("labels.loading")} +
+ ) : error ? ( +
+ {t("pages.agent.load_error")} +
+ ) : ( +
+

+ {t("pages.agent.tools.description")} +

+ + {data?.tools.length ? ( + groupedTools.map(([category, items]) => ( +
+
+ {t(`pages.agent.tools.categories.${category}`)} +
+
+ {items.map((tool) => { + const reasonText = tool.reason_code + ? t(`pages.agent.tools.reasons.${tool.reason_code}`) + : "" + const isPending = + toggleMutation.isPending && + toggleMutation.variables?.name === tool.name + const nextEnabled = tool.status !== "enabled" + + return ( + + +
+
+ + {tool.name} + + + {tool.description} + +
+
+ + +
+
+
+ +
+ {t("pages.agent.tools.config_key", { + key: tool.config_key, + })} +
+ {reasonText ? ( +
+ {reasonText} +
+ ) : null} +
+
+ ) + })} +
+
+ )) + ) : ( + + + {t("pages.agent.tools.empty")} + + + )} +
+ )} +
+
+
+ ) +} + +function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) { + const { t } = useTranslation() + + return ( + + {t(`pages.agent.tools.status.${status}`)} + + ) +} diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 7735ad928..4ce615dcf 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -1,6 +1,8 @@ import dayjs from "dayjs" import { useAtomValue } from "jotai" import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" import { getPicoToken } from "@/api/pico" import { getSessionHistory } from "@/api/sessions" @@ -100,6 +102,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string { } export function usePicoChat() { + const { t } = useTranslation() const { status: gatewayState } = useAtomValue(gatewayAtom) const [messages, setMessages] = useState([]) const [connectionState, setConnectionState] = @@ -317,43 +320,38 @@ export function usePicoChat() { // Switch to a historical session const switchSession = useCallback( async (sessionId: string) => { - // Disconnect current WebSocket - disconnect() - - // Set new session ID - setActiveSessionId(sessionId) - setIsTyping(false) - - // Load history from backend - try { - const detail = await getSessionHistory(sessionId) - // Set all history messages timestamp from the session updated time as fallback, - // since currently the backend doesn't return per-message timestamp in the history API. - // We'll use the session's updated time for now. - const fallbackTime = detail.updated - - setMessages( - detail.messages.map((m, i) => ({ - id: `hist-${i}-${Date.now()}`, - role: m.role as "user" | "assistant", - content: m.content, - timestamp: fallbackTime, - })), - ) - } catch (err) { - console.error("Failed to load session history:", err) - setMessages([]) + if (sessionId === activeSessionIdRef.current) { + return + } + + try { + const detail = await getSessionHistory(sessionId) + const fallbackTime = detail.updated + const historyMessages = detail.messages.map((m, i) => ({ + id: `hist-${i}-${Date.now()}`, + role: m.role as "user" | "assistant", + content: m.content, + timestamp: fallbackTime, + })) + + // Only switch the active websocket session after history has loaded successfully. + disconnect() + setActiveSessionId(sessionId) + setIsTyping(false) + setMessages(historyMessages) + } catch (err) { + console.error("Failed to load session history:", err) + toast.error(t("chat.historyOpenFailed")) + return } - // Reconnect with new session ID (will use the updated ref) - // Small delay to ensure state has settled setTimeout(() => { if (gatewayState === "running") { connect() } }, 100) }, - [disconnect, connect, gatewayState], + [connect, disconnect, gatewayState, t], ) // Start a new empty chat diff --git a/web/frontend/src/hooks/use-session-history.ts b/web/frontend/src/hooks/use-session-history.ts index 1a6d5c956..790339dba 100644 --- a/web/frontend/src/hooks/use-session-history.ts +++ b/web/frontend/src/hooks/use-session-history.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions" @@ -13,22 +14,26 @@ export function useSessionHistory({ activeSessionId, onDeletedActiveSession, }: UseSessionHistoryOptions) { + const { t } = useTranslation() const observerRef = useRef(null) const [sessions, setSessions] = useState([]) const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const [isLoadingMore, setIsLoadingMore] = useState(false) + const [loadError, setLoadError] = useState(false) const loadSessions = useCallback( async (reset = true) => { try { const currentOffset = reset ? 0 : offset if (reset) { + setLoadError(false) setHasMore(true) setOffset(0) } const data = await getSessions(currentOffset, LIMIT) + setLoadError(false) if (data.length < LIMIT) { setHasMore(false) @@ -45,8 +50,12 @@ export function useSessionHistory({ } setOffset(currentOffset + data.length) - } catch { - // silently fail + } catch (err) { + console.error("Failed to fetch session history:", err) + setLoadError(true) + if (!reset) { + setHasMore(false) + } } finally { setIsLoadingMore(false) } @@ -55,11 +64,16 @@ export function useSessionHistory({ ) useEffect(() => { - if (!observerRef.current || !hasMore || isLoadingMore) return + if (!observerRef.current || !hasMore || isLoadingMore || loadError) return const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + if ( + entries[0].isIntersecting && + hasMore && + !isLoadingMore && + !loadError + ) { setIsLoadingMore(true) void loadSessions(false) } @@ -69,7 +83,7 @@ export function useSessionHistory({ observer.observe(observerRef.current) return () => observer.disconnect() - }, [hasMore, isLoadingMore, loadSessions]) + }, [hasMore, isLoadingMore, loadError, loadSessions]) const handleDeleteSession = useCallback( async (id: string) => { @@ -89,6 +103,8 @@ export function useSessionHistory({ return { sessions, hasMore, + loadError, + loadErrorMessage: t("chat.historyLoadFailed"), observerRef, loadSessions, handleDeleteSession, diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts index 0848af468..5579a955b 100644 --- a/web/frontend/src/hooks/use-sidebar-channels.ts +++ b/web/frontend/src/hooks/use-sidebar-channels.ts @@ -27,7 +27,7 @@ import { import { getChannelDisplayName } from "@/components/channels/channel-display-name" import { gatewayAtom } from "@/store/gateway" -const DEFAULT_VISIBLE_CHANNELS = 5 +const DEFAULT_VISIBLE_CHANNELS = 4 const CHANNEL_IMPORTANCE_ORDER = [ "discord", "feishu", diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index f1ed0ac16..875387c8a 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -4,6 +4,9 @@ "model_group": "Models", "models": "Models", "credentials": "Credentials", + "agent_group": "Agent", + "skills": "Skills", + "tools": "Tools", "services": "Services", "channels_group": "Channels", "show_more_channels": "More", @@ -25,6 +28,8 @@ }, "history": "History", "noHistory": "No chat history yet", + "historyLoadFailed": "Failed to load chat history", + "historyOpenFailed": "Failed to open this chat history", "loadingMore": "Loading more...", "deleteSession": "Delete session", "messagesCount": "{{count}} messages", @@ -324,6 +329,100 @@ } }, "pages": { + "agent": { + "load_error": "Failed to load agent support information.", + "stats": { + "workspace": "Workspace", + "workspace_hint": "The default agent workspace used for runtime files and workspace skills.", + "skills": "Available Skills", + "skills_hint": "Skills discovered from workspace, global, and builtin roots.", + "tools": "Enabled Tools", + "tools_hint": "{{blocked}} blocked by missing dependencies." + }, + "skills": { + "title": "Skills", + "description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.", + "hero_title": "Skill Library", + "hero_description": "Browse every capability package the agent can load, then drill straight into the effective SKILL.md without leaving the page.", + "stats": { + "total": "Total Skills", + "workspace": "Workspace", + "shared": "Shared" + }, + "empty": "No skills are currently available.", + "import": "Import Skill", + "import_title": "Import Skill", + "import_description": "Create a workspace skill by uploading a markdown file as the new SKILL.md.", + "import_name": "Skill Name", + "import_name_placeholder": "e.g. my-workflow", + "import_file": "Markdown File", + "import_file_hint": "Upload a .md file. The backend stores it as workspace/skills//SKILL.md.", + "import_confirm": "Import Skill", + "import_success": "Skill imported.", + "import_error": "Failed to import skill.", + "view": "View", + "delete": "Delete", + "delete_title": "Delete Skill?", + "delete_description": "\"{{name}}\" will be removed from workspace skills.", + "delete_confirm": "Delete", + "delete_success": "Skill deleted.", + "delete_error": "Failed to delete skill.", + "viewer_title": "Skill Content", + "viewer_description": "Read the current effective SKILL.md content here.", + "loading_detail": "Loading skill content...", + "load_detail_error": "Failed to load skill content.", + "source": "Source", + "path": "Skill Path", + "no_description": "No description provided.", + "sources": { + "workspace": "Workspace", + "global": "Global", + "builtin": "Builtin" + }, + "errors": { + "file_required": "Please choose a markdown file to import." + } + }, + "tools": { + "title": "Tools", + "description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.", + "hero_title": "Tool Surface", + "hero_description": "Inspect what the agent can actually call right now, which capabilities are blocked, and where each tool is controlled in config.", + "stats": { + "enabled": "Enabled", + "blocked": "Blocked", + "categories": "Categories" + }, + "empty": "No tools are available.", + "enable": "Enable", + "disable": "Disable", + "enable_success": "Tool enabled.", + "disable_success": "Tool disabled.", + "toggle_error": "Failed to update tool state.", + "config_key": "Controlled by tools.{{key}}", + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "blocked": "Blocked" + }, + "categories": { + "automation": "Automation", + "filesystem": "Filesystem", + "web": "Web", + "communication": "Communication", + "skills": "Skills", + "agents": "Agents", + "hardware": "Hardware", + "discovery": "Discovery" + }, + "reasons": { + "requires_linux": "This tool only works on Linux hosts with the required device files exposed.", + "requires_skills": "Enable `tools.skills` before this skill-registry tool can be used.", + "requires_subagent": "Enable `tools.subagent` before the spawn tool can delegate work.", + "requires_mcp_discovery": "Enable `tools.mcp.discovery` before MCP discovery tools become available." + } + } + }, "config": { "load_error": "Failed to load configuration. Please refresh and try again.", "workspace": "Workspace Directory", @@ -387,7 +486,9 @@ "unsaved_changes": "You have unsaved changes." }, "logs": { - "description": "System logs and monitoring." + "description": "System logs and monitoring.", + "clear": "Clear logs", + "empty": "Waiting for logs..." } } } diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index b66f0f03d..e40ad625b 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -4,6 +4,9 @@ "model_group": "模型", "models": "模型", "credentials": "凭据", + "agent_group": "智能体", + "skills": "技能", + "tools": "工具", "services": "服务", "channels_group": "频道", "show_more_channels": "更多", @@ -25,6 +28,8 @@ }, "history": "历史记录", "noHistory": "暂无对话历史", + "historyLoadFailed": "加载历史记录失败", + "historyOpenFailed": "打开该历史会话失败", "loadingMore": "加载更多...", "deleteSession": "删除会话", "messagesCount": "{{count}} 条消息", @@ -324,6 +329,100 @@ } }, "pages": { + "agent": { + "load_error": "加载 Agent 支持信息失败。", + "stats": { + "workspace": "工作目录", + "workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。", + "skills": "可用技能数", + "skills_hint": "从工作区、全局目录和内置目录发现的技能。", + "tools": "已启用工具", + "tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。" + }, + "skills": { + "title": "技能", + "description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。", + "hero_title": "技能库", + "hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。", + "stats": { + "total": "技能总数", + "workspace": "工作区技能", + "shared": "共享技能" + }, + "empty": "当前没有可用技能。", + "import": "导入技能", + "import_title": "导入技能", + "import_description": "通过上传 Markdown 文件创建工作区技能,文件会保存为新的 SKILL.md。", + "import_name": "技能名称", + "import_name_placeholder": "例如 my-workflow", + "import_file": "Markdown 文件", + "import_file_hint": "上传一个 .md 文件。后端会保存到 workspace/skills//SKILL.md。", + "import_confirm": "导入技能", + "import_success": "技能导入成功。", + "import_error": "导入技能失败。", + "view": "查看", + "delete": "删除", + "delete_title": "删除技能?", + "delete_description": "将从工作区技能中移除「{{name}}」。", + "delete_confirm": "删除", + "delete_success": "技能已删除。", + "delete_error": "删除技能失败。", + "viewer_title": "技能内容", + "viewer_description": "这里展示当前生效的 SKILL.md 内容。", + "loading_detail": "正在加载技能内容...", + "load_detail_error": "加载技能内容失败。", + "source": "来源", + "path": "技能路径", + "no_description": "未提供描述。", + "sources": { + "workspace": "工作区", + "global": "全局", + "builtin": "内置" + }, + "errors": { + "file_required": "请先选择要导入的 Markdown 文件。" + } + }, + "tools": { + "title": "工具", + "description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。", + "hero_title": "工具面板", + "hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。", + "stats": { + "enabled": "已启用", + "blocked": "被阻塞", + "categories": "分类数" + }, + "empty": "当前没有可用工具。", + "enable": "启用", + "disable": "禁用", + "enable_success": "工具已启用。", + "disable_success": "工具已禁用。", + "toggle_error": "更新工具状态失败。", + "config_key": "由 tools.{{key}} 控制", + "status": { + "enabled": "已启用", + "disabled": "已禁用", + "blocked": "被阻塞" + }, + "categories": { + "automation": "自动化", + "filesystem": "文件系统", + "web": "网页", + "communication": "通信", + "skills": "技能", + "agents": "Agent", + "hardware": "硬件", + "discovery": "发现" + }, + "reasons": { + "requires_linux": "该工具仅在 Linux 主机上可用,并且需要暴露对应的设备文件。", + "requires_skills": "需要先启用 `tools.skills`,该技能注册表工具才能使用。", + "requires_subagent": "需要先启用 `tools.subagent`,`spawn` 才能委派任务。", + "requires_mcp_discovery": "需要先启用 `tools.mcp.discovery`,MCP 发现工具才会可用。" + } + } + }, "config": { "load_error": "加载配置失败,请刷新后重试。", "workspace": "工作目录", @@ -387,7 +486,9 @@ "unsaved_changes": "您有未保存的更改。" }, "logs": { - "description": "系统日志和监控。" + "description": "系统日志和监控。", + "clear": "清空日志", + "empty": "等待日志中..." } } } diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 336504075..60f19ab53 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -13,10 +13,13 @@ import { Route as ModelsRouteImport } from './routes/models' import { Route as LogsRouteImport } from './routes/logs' import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as ConfigRouteImport } from './routes/config' +import { Route as AgentRouteImport } from './routes/agent' import { Route as ChannelsRouteRouteImport } from './routes/channels/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ConfigRawRouteImport } from './routes/config.raw' import { Route as ChannelsNameRouteImport } from './routes/channels/$name' +import { Route as AgentToolsRouteImport } from './routes/agent/tools' +import { Route as AgentSkillsRouteImport } from './routes/agent/skills' const ModelsRoute = ModelsRouteImport.update({ id: '/models', @@ -38,6 +41,11 @@ const ConfigRoute = ConfigRouteImport.update({ path: '/config', getParentRoute: () => rootRouteImport, } as any) +const AgentRoute = AgentRouteImport.update({ + id: '/agent', + path: '/agent', + getParentRoute: () => rootRouteImport, +} as any) const ChannelsRouteRoute = ChannelsRouteRouteImport.update({ id: '/channels', path: '/channels', @@ -58,24 +66,40 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({ path: '/$name', getParentRoute: () => ChannelsRouteRoute, } as any) +const AgentToolsRoute = AgentToolsRouteImport.update({ + id: '/tools', + path: '/tools', + getParentRoute: () => AgentRoute, +} as any) +const AgentSkillsRoute = AgentSkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => AgentRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/channels': typeof ChannelsRouteRouteWithChildren + '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute + '/agent/skills': typeof AgentSkillsRoute + '/agent/tools': typeof AgentToolsRoute '/channels/$name': typeof ChannelsNameRoute '/config/raw': typeof ConfigRawRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/channels': typeof ChannelsRouteRouteWithChildren + '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute + '/agent/skills': typeof AgentSkillsRoute + '/agent/tools': typeof AgentToolsRoute '/channels/$name': typeof ChannelsNameRoute '/config/raw': typeof ConfigRawRoute } @@ -83,10 +107,13 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/channels': typeof ChannelsRouteRouteWithChildren + '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute + '/agent/skills': typeof AgentSkillsRoute + '/agent/tools': typeof AgentToolsRoute '/channels/$name': typeof ChannelsNameRoute '/config/raw': typeof ConfigRawRoute } @@ -95,30 +122,39 @@ export interface FileRouteTypes { fullPaths: | '/' | '/channels' + | '/agent' | '/config' | '/credentials' | '/logs' | '/models' + | '/agent/skills' + | '/agent/tools' | '/channels/$name' | '/config/raw' fileRoutesByTo: FileRoutesByTo to: | '/' | '/channels' + | '/agent' | '/config' | '/credentials' | '/logs' | '/models' + | '/agent/skills' + | '/agent/tools' | '/channels/$name' | '/config/raw' id: | '__root__' | '/' | '/channels' + | '/agent' | '/config' | '/credentials' | '/logs' | '/models' + | '/agent/skills' + | '/agent/tools' | '/channels/$name' | '/config/raw' fileRoutesById: FileRoutesById @@ -126,6 +162,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren + AgentRoute: typeof AgentRouteWithChildren ConfigRoute: typeof ConfigRouteWithChildren CredentialsRoute: typeof CredentialsRoute LogsRoute: typeof LogsRoute @@ -162,6 +199,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConfigRouteImport parentRoute: typeof rootRouteImport } + '/agent': { + id: '/agent' + path: '/agent' + fullPath: '/agent' + preLoaderRoute: typeof AgentRouteImport + parentRoute: typeof rootRouteImport + } '/channels': { id: '/channels' path: '/channels' @@ -190,6 +234,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChannelsNameRouteImport parentRoute: typeof ChannelsRouteRoute } + '/agent/tools': { + id: '/agent/tools' + path: '/tools' + fullPath: '/agent/tools' + preLoaderRoute: typeof AgentToolsRouteImport + parentRoute: typeof AgentRoute + } + '/agent/skills': { + id: '/agent/skills' + path: '/skills' + fullPath: '/agent/skills' + preLoaderRoute: typeof AgentSkillsRouteImport + parentRoute: typeof AgentRoute + } } } @@ -205,6 +263,18 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren( ChannelsRouteRouteChildren, ) +interface AgentRouteChildren { + AgentSkillsRoute: typeof AgentSkillsRoute + AgentToolsRoute: typeof AgentToolsRoute +} + +const AgentRouteChildren: AgentRouteChildren = { + AgentSkillsRoute: AgentSkillsRoute, + AgentToolsRoute: AgentToolsRoute, +} + +const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren) + interface ConfigRouteChildren { ConfigRawRoute: typeof ConfigRawRoute } @@ -219,6 +289,7 @@ const ConfigRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ChannelsRouteRoute: ChannelsRouteRouteWithChildren, + AgentRoute: AgentRouteWithChildren, ConfigRoute: ConfigRouteWithChildren, CredentialsRoute: CredentialsRoute, LogsRoute: LogsRoute, diff --git a/web/frontend/src/routes/agent.tsx b/web/frontend/src/routes/agent.tsx new file mode 100644 index 000000000..78104de5b --- /dev/null +++ b/web/frontend/src/routes/agent.tsx @@ -0,0 +1,22 @@ +import { + Navigate, + Outlet, + createFileRoute, + useRouterState, +} from "@tanstack/react-router" + +export const Route = createFileRoute("/agent")({ + component: AgentLayout, +}) + +function AgentLayout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + + if (pathname === "/agent") { + return + } + + return +} diff --git a/web/frontend/src/routes/agent/skills.tsx b/web/frontend/src/routes/agent/skills.tsx new file mode 100644 index 000000000..bbe396bdb --- /dev/null +++ b/web/frontend/src/routes/agent/skills.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { SkillsPage } from "@/components/skills/skills-page" + +export const Route = createFileRoute("/agent/skills")({ + component: AgentSkillsRoute, +}) + +function AgentSkillsRoute() { + return +} diff --git a/web/frontend/src/routes/agent/tools.tsx b/web/frontend/src/routes/agent/tools.tsx new file mode 100644 index 000000000..ac8738a8f --- /dev/null +++ b/web/frontend/src/routes/agent/tools.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { ToolsPage } from "@/components/tools/tools-page" + +export const Route = createFileRoute("/agent/tools")({ + component: AgentToolsRoute, +}) + +function AgentToolsRoute() { + return +} diff --git a/web/frontend/src/routes/logs.tsx b/web/frontend/src/routes/logs.tsx index 39688bd84..ef39e0bdf 100644 --- a/web/frontend/src/routes/logs.tsx +++ b/web/frontend/src/routes/logs.tsx @@ -1,10 +1,12 @@ +import { IconTrash } from "@tabler/icons-react" import { createFileRoute } from "@tanstack/react-router" import { useAtomValue } from "jotai" import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { getGatewayStatus } from "@/api/gateway" +import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway" import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { gatewayAtom } from "@/store/gateway" @@ -15,12 +17,31 @@ export const Route = createFileRoute("/logs")({ function LogsPage() { const { t } = useTranslation() const [logs, setLogs] = useState([]) + const [clearing, setClearing] = useState(false) const logOffsetRef = useRef(0) const logRunIdRef = useRef(-1) + const syncTokenRef = useRef(0) const scrollRef = useRef(null) const gateway = useAtomValue(gatewayAtom) + const handleClearLogs = async () => { + setClearing(true) + try { + const data = await clearGatewayLogs() + syncTokenRef.current += 1 + setLogs([]) + logOffsetRef.current = data.log_total ?? 0 + if (data.log_run_id !== undefined) { + logRunIdRef.current = data.log_run_id + } + } catch { + // Ignore clear failures silently to avoid noisy transient errors. + } finally { + setClearing(false) + } + } + useEffect(() => { let mounted = true let timeout: ReturnType @@ -40,17 +61,17 @@ function LogsPage() { } try { + const requestToken = syncTokenRef.current + const requestOffset = logOffsetRef.current + const requestRunId = logRunIdRef.current const data = await getGatewayStatus({ - log_offset: logOffsetRef.current, - log_run_id: logRunIdRef.current, + log_offset: requestOffset, + log_run_id: requestRunId, }) - if (!mounted) return + if (!mounted || requestToken !== syncTokenRef.current) return - if ( - data.log_run_id !== undefined && - data.log_run_id !== logRunIdRef.current - ) { + if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) { logRunIdRef.current = data.log_run_id logOffsetRef.current = 0 if (data.logs) { @@ -90,13 +111,25 @@ function LogsPage() {
-
-

- {t("navigation.logs")} -

-

- {t("pages.logs.description")} -

+
+
+

+ {t("navigation.logs")} +

+

+ {t("pages.logs.description")} +

+
+ +
@@ -104,7 +137,7 @@ function LogsPage() {
{logs.length === 0 ? (
- Waiting for logs... + {t("pages.logs.empty")}
) : ( logs.map((log, i) => (