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
This commit is contained in:
parent
8a398988d7
commit
dea06c391c
45 changed files with 4266 additions and 327 deletions
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
@ -17,36 +16,11 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
|
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.
|
// handleGetConfig returns the complete system configuration.
|
||||||
//
|
//
|
||||||
// GET /api/config
|
// GET /api/config
|
||||||
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, err := h.loadFilteredConfig()
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -19,6 +18,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gateway holds the state for the managed gateway process.
|
// gateway holds the state for the managed gateway process.
|
||||||
|
|
@ -36,6 +36,7 @@ var gateway = struct {
|
||||||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||||
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
|
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/start", h.handleGatewayStart)
|
||||||
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
||||||
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
|
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
|
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
|
if !hasModelConfiguration(*modelCfg) {
|
||||||
strings.TrimSpace(modelCfg.AuthMethod) != ""
|
|
||||||
if !hasCredential {
|
|
||||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
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
|
return true, "", nil
|
||||||
}
|
}
|
||||||
|
|
@ -131,14 +133,18 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
||||||
|
|
||||||
func (h *Handler) startGatewayLocked() (int, error) {
|
func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
// Locate the picoclaw executable
|
// Locate the picoclaw executable
|
||||||
execPath := findPicoclawBinary()
|
execPath := utils.FindPicoclawBinary()
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
cmd := exec.Command(execPath, "gateway")
|
||||||
|
cmd.Env = os.Environ()
|
||||||
// Forward the launcher's config path via the environment variable that
|
// Forward the launcher's config path via the environment variable that
|
||||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
// config file without requiring a --config flag on the gateway subcommand.
|
// config file without requiring a --config flag on the gateway subcommand.
|
||||||
if h.configPath != "" {
|
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()
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
|
@ -207,10 +213,7 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
healthHost := "127.0.0.1"
|
healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
|
||||||
healthHost = cfg.Gateway.Host
|
|
||||||
}
|
|
||||||
healthPort := cfg.Gateway.Port
|
healthPort := cfg.Gateway.Port
|
||||||
if healthPort == 0 {
|
if healthPort == 0 {
|
||||||
healthPort = 18790
|
healthPort = 18790
|
||||||
|
|
@ -353,6 +356,20 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
h.handleGatewayStart(w, r)
|
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.
|
// handleGatewayStatus returns the gateway run status, health info, and logs.
|
||||||
//
|
//
|
||||||
// GET /api/gateway/status
|
// GET /api/gateway/status
|
||||||
|
|
@ -375,9 +392,7 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
host := "127.0.0.1"
|
host := "127.0.0.1"
|
||||||
port := 18790
|
port := 18790
|
||||||
if err == nil && cfg != nil {
|
if err == nil && cfg != nil {
|
||||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
host = cfg.Gateway.Host
|
|
||||||
}
|
|
||||||
if cfg.Gateway.Port != 0 {
|
if cfg.Gateway.Port != 0 {
|
||||||
port = cfg.Gateway.Port
|
port = cfg.Gateway.Port
|
||||||
}
|
}
|
||||||
|
|
@ -535,36 +550,6 @@ func (h *Handler) currentGatewayStatus() string {
|
||||||
return string(encoded)
|
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.
|
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
|
||||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
|
|
|
||||||
66
web/backend/api/gateway_host.go
Normal file
66
web/backend/api/gateway_host.go
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
59
web/backend/api/gateway_host_test.go
Normal file
59
web/backend/api/gateway_host_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,13 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||||
|
|
@ -32,7 +35,8 @@ func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.Model = "missing-model"
|
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)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +58,8 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = "test-key"
|
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)
|
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.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
cfg.ModelList[0].APIKey = ""
|
cfg.ModelList[0].APIKey = ""
|
||||||
cfg.ModelList[0].AuthMethod = ""
|
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)
|
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) {
|
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
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) {
|
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
|
||||||
// Create a temporary file to act as the mock binary
|
// Create a temporary file to act as the mock binary
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
@ -132,9 +392,9 @@ func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
|
||||||
|
|
||||||
t.Setenv("PICOCLAW_BINARY", mockBinary)
|
t.Setenv("PICOCLAW_BINARY", mockBinary)
|
||||||
|
|
||||||
got := findPicoclawBinary()
|
got := utils.FindPicoclawBinary()
|
||||||
if got != mockBinary {
|
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
|
// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy
|
||||||
t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary")
|
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
|
// Should not return the invalid path; falls back to "picoclaw" or another found path
|
||||||
if got == "/nonexistent/picoclaw-binary" {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import "sync"
|
||||||
|
|
||||||
// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines.
|
// 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
|
// 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 {
|
type LogBuffer struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
lines []string
|
lines []string
|
||||||
|
|
@ -45,6 +45,12 @@ func (b *LogBuffer) Reset() {
|
||||||
b.runID++
|
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.
|
// 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.
|
// 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) {
|
func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) {
|
||||||
|
|
|
||||||
324
web/backend/api/model_status.go
Normal file
324
web/backend/api/model_status.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
@ -45,13 +46,24 @@ type modelResponse struct {
|
||||||
//
|
//
|
||||||
// GET /api/models
|
// GET /api/models
|
||||||
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, err := h.loadFilteredConfig()
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultModel := cfg.Agents.Defaults.GetModelName()
|
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))
|
models := make([]modelResponse, 0, len(cfg.ModelList))
|
||||||
for i, m := range 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,
|
MaxTokensField: m.MaxTokensField,
|
||||||
RequestTimeout: m.RequestTimeout,
|
RequestTimeout: m.RequestTimeout,
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
Configured: m.APIKey != "" || m.AuthMethod != "",
|
Configured: configured[i],
|
||||||
IsDefault: m.ModelName == defaultModel,
|
IsDefault: m.ModelName == defaultModel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
313
web/backend/api/models_test.go
Normal file
313
web/backend/api/models_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -30,7 +28,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := buildWsURL(r, cfg)
|
wsURL := h.buildWsURL(r, cfg)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|
@ -58,7 +56,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|
@ -123,7 +121,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := buildWsURL(r, cfg)
|
wsURL := h.buildWsURL(r, cfg)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
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.
|
// generateSecureToken creates a random 32-character hex string.
|
||||||
func generateSecureToken() string {
|
func generateSecureToken() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ import (
|
||||||
|
|
||||||
// Handler serves HTTP API requests.
|
// Handler serves HTTP API requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
configPath string
|
configPath string
|
||||||
serverPort int
|
serverPort int
|
||||||
serverPublic bool
|
serverPublic bool
|
||||||
serverCIDRs []string
|
serverPublicExplicit bool
|
||||||
oauthMu sync.Mutex
|
serverCIDRs []string
|
||||||
oauthFlows map[string]*oauthFlow
|
oauthMu sync.Mutex
|
||||||
oauthState map[string]string
|
oauthFlows map[string]*oauthFlow
|
||||||
|
oauthState map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates an instance of the API handler.
|
// 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.
|
// 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.serverPort = port
|
||||||
h.serverPublic = public
|
h.serverPublic = public
|
||||||
|
h.serverPublicExplicit = publicExplicit
|
||||||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +60,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Channel catalog (for frontend navigation/config pages)
|
// Channel catalog (for frontend navigation/config pages)
|
||||||
h.registerChannelRoutes(mux)
|
h.registerChannelRoutes(mux)
|
||||||
|
|
||||||
|
// Skills and tools support/actions
|
||||||
|
h.registerSkillRoutes(mux)
|
||||||
|
h.registerToolRoutes(mux)
|
||||||
|
|
||||||
// OS startup / launch-at-login
|
// OS startup / launch-at-login
|
||||||
h.registerStartupRoutes(mux)
|
h.registerStartupRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -33,12 +35,22 @@ type sessionFile struct {
|
||||||
// sessionListItem is a lightweight summary returned by GET /api/sessions.
|
// sessionListItem is a lightweight summary returned by GET /api/sessions.
|
||||||
type sessionListItem struct {
|
type sessionListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
Preview string `json:"preview"`
|
Preview string `json:"preview"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
Created string `json:"created"`
|
Created string `json:"created"`
|
||||||
Updated string `json:"updated"`
|
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
|
// picoSessionPrefix is the key prefix used by the gateway's routing for Pico
|
||||||
// channel sessions. The full key format is:
|
// channel sessions. The full key format is:
|
||||||
//
|
//
|
||||||
|
|
@ -47,7 +59,12 @@ type sessionListItem struct {
|
||||||
// The sanitized filename replaces ':' with '_', so on disk it becomes:
|
// The sanitized filename replaces ':' with '_', so on disk it becomes:
|
||||||
//
|
//
|
||||||
// agent_main_pico_direct_pico_<session-uuid>.json
|
// agent_main_pico_direct_pico_<session-uuid>.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.
|
// extractPicoSessionID extracts the session UUID from a full session key.
|
||||||
// Returns the UUID and true if the key matches the Pico session pattern.
|
// 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
|
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.
|
// sessionsDir resolves the path to the gateway's session storage directory.
|
||||||
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
|
// It reads the workspace from config, falling back to ~/.picoclaw/workspace.
|
||||||
func (h *Handler) sessionsDir() (string, error) {
|
func (h *Handler) sessionsDir() (string, error) {
|
||||||
|
|
@ -104,58 +293,76 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
items := []sessionListItem{}
|
items := []sessionListItem{}
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
name := entry.Name()
|
||||||
if err != nil {
|
var (
|
||||||
continue
|
sessionID string
|
||||||
}
|
sess sessionFile
|
||||||
|
loadErr error
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
|
||||||
var sess sessionFile
|
switch {
|
||||||
if err := json.Unmarshal(data, &sess); err != nil {
|
case strings.HasSuffix(name, ".jsonl"):
|
||||||
continue
|
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
|
||||||
}
|
if !ok {
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
}
|
sess, loadErr = h.readJSONLSession(dir, sessionID)
|
||||||
if len([]rune(preview)) > 60 {
|
if loadErr == nil && isEmptySession(sess) {
|
||||||
preview = string([]rune(preview)[:60]) + "..."
|
continue
|
||||||
}
|
|
||||||
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++
|
|
||||||
}
|
}
|
||||||
|
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{
|
if loadErr != nil {
|
||||||
ID: sessionID,
|
continue
|
||||||
Preview: preview,
|
}
|
||||||
MessageCount: validMessageCount,
|
if _, exists := seen[sessionID]; exists {
|
||||||
Created: sess.Created.Format(time.RFC3339),
|
continue
|
||||||
Updated: sess.Updated.Format(time.RFC3339),
|
}
|
||||||
})
|
|
||||||
|
seen[sessionID] = struct{}{}
|
||||||
|
items = append(items, buildSessionListItem(sessionID, sess))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by updated descending (most recent first)
|
// Sort by updated descending (most recent first)
|
||||||
|
|
@ -209,20 +416,25 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sanitized filename replaces ':' with '_':
|
sess, err := h.readJSONLSession(dir, sessionID)
|
||||||
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
|
if err == nil && isEmptySession(sess) {
|
||||||
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
|
err = os.ErrNotExist
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "session not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
var sess sessionFile
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
if err := json.Unmarshal(data, &sess); err != nil {
|
sess, err = h.readLegacySession(dir, sessionID)
|
||||||
http.Error(w, "failed to parse session", http.StatusInternalServerError)
|
if err == nil && isEmptySession(sess) {
|
||||||
return
|
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
|
// Convert to a simpler format for the frontend
|
||||||
|
|
@ -268,17 +480,25 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sanitized filename replaces ':' with '_':
|
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
|
||||||
// agent:main:pico:direct:pico:<uuid> -> agent_main_pico_direct_pico_<uuid>.json
|
jsonlPath := base + ".jsonl"
|
||||||
filename := strings.ReplaceAll(picoSessionPrefix+sessionID, ":", "_") + ".json"
|
metaPath := base + ".meta.json"
|
||||||
filePath := filepath.Join(dir, filename)
|
legacyPath := base + ".json"
|
||||||
|
|
||||||
if err := os.Remove(filePath); err != nil {
|
removed := false
|
||||||
if os.IsNotExist(err) {
|
for _, path := range []string{jsonlPath, metaPath, legacyPath} {
|
||||||
http.Error(w, "session not found", http.StatusNotFound)
|
if err := os.Remove(path); err != nil {
|
||||||
} else {
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
http.Error(w, "failed to delete session", http.StatusInternalServerError)
|
http.Error(w, "failed to delete session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !removed {
|
||||||
|
http.Error(w, "session not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
322
web/backend/api/session_test.go
Normal file
322
web/backend/api/session_test.go
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
331
web/backend/api/skills.go
Normal file
331
web/backend/api/skills.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
336
web/backend/api/skills_test.go
Normal file
336
web/backend/api/skills_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
323
web/backend/api/tools.go
Normal file
323
web/backend/api/tools.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
198
web/backend/api/tools_test.go
Normal file
198
web/backend/api/tools_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/backend/dist/.gitkeep
vendored
1
web/backend/dist/.gitkeep
vendored
|
|
@ -0,0 +1 @@
|
||||||
|
# Keep the embedded web backend dist directory in version control.
|
||||||
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/web/backend/api"
|
"github.com/sipeed/picoclaw/web/backend/api"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -51,7 +52,7 @@ func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Resolve config path
|
// Resolve config path
|
||||||
configPath := getDefaultConfigPath()
|
configPath := utils.GetDefaultConfigPath()
|
||||||
if flag.NArg() > 0 {
|
if flag.NArg() > 0 {
|
||||||
configPath = flag.Arg(0)
|
configPath = flag.Arg(0)
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +61,10 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to resolve config path: %v", err)
|
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 explicitPort bool
|
||||||
var explicitPublic bool
|
var explicitPublic bool
|
||||||
|
|
@ -109,7 +114,7 @@ func main() {
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler := api.NewHandler(absPath)
|
apiHandler := api.NewHandler(absPath)
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, launcherCfg.AllowedCIDRs)
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
|
|
@ -128,13 +133,13 @@ func main() {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Print startup banner
|
// Print startup banner
|
||||||
fmt.Print(banner)
|
fmt.Print(utils.Banner)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" Open the following URL in your browser:")
|
fmt.Println(" Open the following URL in your browser:")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
||||||
if effectivePublic {
|
if effectivePublic {
|
||||||
if ip := getLocalIP(); ip != "" {
|
if ip := utils.GetLocalIP(); ip != "" {
|
||||||
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +150,7 @@ func main() {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
url := "http://localhost:" + effectivePort
|
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)
|
log.Printf("Warning: Failed to auto-open browser: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
package main
|
package utils
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
colorBlue = "\x1b[38;2;62;93;185m"
|
colorBlue = "\x1b[38;2;62;93;185m"
|
||||||
colorRed = "\x1b[38;2;213;70;70m"
|
colorRed = "\x1b[38;2;213;70;70m"
|
||||||
colorReset = "\x1b[0m"
|
colorReset = "\x1b[0m"
|
||||||
banner = "\r\n" +
|
Banner = "\r\n" +
|
||||||
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||||
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||||
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||||
|
|
@ -22,40 +13,3 @@ const (
|
||||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" +
|
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" +
|
||||||
colorReset
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
web/backend/utils/onboard.go
Normal file
42
web/backend/utils/onboard.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
101
web/backend/utils/onboard_test.go
Normal file
101
web/backend/utils/onboard_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
web/backend/utils/runtime.go
Normal file
80
web/backend/utils/runtime.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^4.0.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|
|
||||||
62
web/frontend/pnpm-lock.yaml
generated
62
web/frontend/pnpm-lock.yaml
generated
|
|
@ -4,6 +4,11 @@ settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
'@hono/node-server': 1.19.11
|
||||||
|
express-rate-limit: 8.3.1
|
||||||
|
hono: 4.12.7
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
|
@ -512,11 +517,11 @@ packages:
|
||||||
'@fontsource-variable/inter@5.2.8':
|
'@fontsource-variable/inter@5.2.8':
|
||||||
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
||||||
|
|
||||||
'@hono/node-server@1.19.9':
|
'@hono/node-server@1.19.11':
|
||||||
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
|
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: 4.12.7
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
|
|
@ -1359,79 +1364,66 @@ packages:
|
||||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||||
|
|
@ -1516,28 +1508,24 @@ packages:
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||||
|
|
@ -2296,8 +2284,8 @@ packages:
|
||||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||||
engines: {node: ^18.19.0 || >=20.5.0}
|
engines: {node: ^18.19.0 || >=20.5.0}
|
||||||
|
|
||||||
express-rate-limit@8.2.1:
|
express-rate-limit@8.3.1:
|
||||||
resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
|
resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
express: '>= 4.11'
|
express: '>= 4.11'
|
||||||
|
|
@ -2496,8 +2484,8 @@ packages:
|
||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
hono@4.12.3:
|
hono@4.12.7:
|
||||||
resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==}
|
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
html-parse-stringify@3.0.1:
|
html-parse-stringify@3.0.1:
|
||||||
|
|
@ -2559,8 +2547,8 @@ packages:
|
||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||||
|
|
||||||
ip-address@10.0.1:
|
ip-address@10.1.0:
|
||||||
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
|
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
|
|
@ -2785,28 +2773,24 @@ packages:
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.31.1:
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.31.1:
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.31.1:
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.31.1:
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||||
|
|
@ -4332,9 +4316,9 @@ snapshots:
|
||||||
|
|
||||||
'@fontsource-variable/inter@5.2.8': {}
|
'@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:
|
dependencies:
|
||||||
hono: 4.12.3
|
hono: 4.12.7
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
|
|
@ -4396,7 +4380,7 @@ snapshots:
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
|
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
|
||||||
dependencies:
|
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: 8.18.0
|
||||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
|
|
@ -4405,8 +4389,8 @@ snapshots:
|
||||||
eventsource: 3.0.7
|
eventsource: 3.0.7
|
||||||
eventsource-parser: 3.0.6
|
eventsource-parser: 3.0.6
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
express-rate-limit: 8.2.1(express@5.2.1)
|
express-rate-limit: 8.3.1(express@5.2.1)
|
||||||
hono: 4.12.3
|
hono: 4.12.7
|
||||||
jose: 6.1.3
|
jose: 6.1.3
|
||||||
json-schema-typed: 8.0.2
|
json-schema-typed: 8.0.2
|
||||||
pkce-challenge: 5.0.1
|
pkce-challenge: 5.0.1
|
||||||
|
|
@ -6146,10 +6130,10 @@ snapshots:
|
||||||
strip-final-newline: 4.0.0
|
strip-final-newline: 4.0.0
|
||||||
yoctocolors: 2.1.2
|
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:
|
dependencies:
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
ip-address: 10.0.1
|
ip-address: 10.1.0
|
||||||
|
|
||||||
express@5.2.1:
|
express@5.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6374,7 +6358,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
hono@4.12.3: {}
|
hono@4.12.7: {}
|
||||||
|
|
||||||
html-parse-stringify@3.0.1:
|
html-parse-stringify@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -6430,7 +6414,7 @@ snapshots:
|
||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
inline-style-parser@0.2.7: {}
|
||||||
|
|
||||||
ip-address@10.0.1: {}
|
ip-address@10.1.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ interface GatewayStatusResponse {
|
||||||
interface GatewayActionResponse {
|
interface GatewayActionResponse {
|
||||||
status: string
|
status: string
|
||||||
pid?: number
|
pid?: number
|
||||||
|
log_total?: number
|
||||||
|
log_run_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
@ -59,4 +61,10 @@ export async function restartGateway(): Promise<GatewayActionResponse> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearGatewayLogs(): Promise<GatewayActionResponse> {
|
||||||
|
return request<GatewayActionResponse>("/api/gateway/logs/clear", {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type { GatewayStatusResponse, GatewayActionResponse }
|
export type { GatewayStatusResponse, GatewayActionResponse }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
id: string
|
id: string
|
||||||
|
title: string
|
||||||
preview: string
|
preview: string
|
||||||
message_count: number
|
message_count: number
|
||||||
created: string
|
created: string
|
||||||
|
|
|
||||||
79
web/frontend/src/api/skills.ts
Normal file
79
web/frontend/src/api/skills.ts
Normal file
|
|
@ -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<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, options)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(res))
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkills(): Promise<SkillsResponse> {
|
||||||
|
return request<SkillsResponse>("/api/skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkill(name: string): Promise<SkillDetailResponse> {
|
||||||
|
return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSkill(file: File): Promise<SkillActionResponse> {
|
||||||
|
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<SkillActionResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSkill(name: string): Promise<SkillActionResponse> {
|
||||||
|
return request<SkillActionResponse>(
|
||||||
|
`/api/skills/${encodeURIComponent(name)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractErrorMessage(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const 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}`
|
||||||
|
}
|
||||||
56
web/frontend/src/api/tools.ts
Normal file
56
web/frontend/src/api/tools.ts
Normal file
|
|
@ -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<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTools(): Promise<ToolsResponse> {
|
||||||
|
return request<ToolsResponse>("/api/tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setToolEnabled(
|
||||||
|
name: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<ToolActionResponse> {
|
||||||
|
return request<ToolActionResponse>(
|
||||||
|
`/api/tools/${encodeURIComponent(name)}/state`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
IconListDetails,
|
IconListDetails,
|
||||||
IconMessageCircle,
|
IconMessageCircle,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconSparkles,
|
||||||
|
IconTools,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
import { Link, useRouterState } from "@tanstack/react-router"
|
import { Link, useRouterState } from "@tanstack/react-router"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
@ -53,6 +55,10 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
|
||||||
label: "navigation.model_group",
|
label: "navigation.model_group",
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "navigation.agent_group",
|
||||||
|
defaultOpen: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "navigation.services",
|
label: "navigation.services",
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
|
|
@ -113,6 +119,23 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...baseNavGroups[2],
|
...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: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "navigation.config",
|
title: "navigation.config",
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,18 @@ export function ChatPage() {
|
||||||
handleSetDefault,
|
handleSetDefault,
|
||||||
} = useChatModels({ isConnected })
|
} = useChatModels({ isConnected })
|
||||||
|
|
||||||
const { sessions, hasMore, observerRef, loadSessions, handleDeleteSession } =
|
const {
|
||||||
useSessionHistory({
|
sessions,
|
||||||
activeSessionId,
|
hasMore,
|
||||||
onDeletedActiveSession: newChat,
|
loadError,
|
||||||
})
|
loadErrorMessage,
|
||||||
|
observerRef,
|
||||||
|
loadSessions,
|
||||||
|
handleDeleteSession,
|
||||||
|
} = useSessionHistory({
|
||||||
|
activeSessionId,
|
||||||
|
onDeletedActiveSession: newChat,
|
||||||
|
})
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
||||||
|
|
@ -96,6 +103,8 @@ export function ChatPage() {
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
loadError={loadError}
|
||||||
|
loadErrorMessage={loadErrorMessage}
|
||||||
observerRef={observerRef}
|
observerRef={observerRef}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ interface SessionHistoryMenuProps {
|
||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[]
|
||||||
activeSessionId: string
|
activeSessionId: string
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
|
loadError: boolean
|
||||||
|
loadErrorMessage: string
|
||||||
observerRef: RefObject<HTMLDivElement | null>
|
observerRef: RefObject<HTMLDivElement | null>
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSwitchSession: (sessionId: string) => void
|
onSwitchSession: (sessionId: string) => void
|
||||||
|
|
@ -27,6 +29,8 @@ export function SessionHistoryMenu({
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
loadError,
|
||||||
|
loadErrorMessage,
|
||||||
observerRef,
|
observerRef,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSwitchSession,
|
onSwitchSession,
|
||||||
|
|
@ -44,7 +48,14 @@ export function SessionHistoryMenu({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-72">
|
<DropdownMenuContent align="end" className="w-72">
|
||||||
<ScrollArea className="max-h-[300px]">
|
<ScrollArea className="max-h-[300px]">
|
||||||
{sessions.length === 0 ? (
|
{loadError && (
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<span className="text-destructive text-xs">
|
||||||
|
{loadErrorMessage}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sessions.length === 0 && !loadError ? (
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem disabled>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{t("chat.noHistory")}
|
{t("chat.noHistory")}
|
||||||
|
|
@ -60,7 +71,7 @@ export function SessionHistoryMenu({
|
||||||
onClick={() => onSwitchSession(session.id)}
|
onClick={() => onSwitchSession(session.id)}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-1 text-sm font-medium">
|
<span className="line-clamp-1 text-sm font-medium">
|
||||||
{session.preview}
|
{session.title || session.preview}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{t("chat.messagesCount", {
|
{t("chat.messagesCount", {
|
||||||
|
|
|
||||||
314
web/frontend/src/components/skills/skills-page.tsx
Normal file
314
web/frontend/src/components/skills/skills-page.tsx
Normal file
|
|
@ -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<HTMLInputElement | null>(null)
|
||||||
|
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [skillPendingDelete, setSkillPendingDelete] =
|
||||||
|
useState<SkillSupportItem | null>(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<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
importMutation.mutate(file)
|
||||||
|
event.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title={t("navigation.skills")}
|
||||||
|
children={
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".md,text/markdown,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportFileChange}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleImportClick}
|
||||||
|
disabled={importMutation.isPending}
|
||||||
|
>
|
||||||
|
{importMutation.isPending ? (
|
||||||
|
<IconLoader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<IconPlus className="size-4" />
|
||||||
|
)}
|
||||||
|
{t("pages.agent.skills.import")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-3">
|
||||||
|
<div className="w-full max-w-6xl space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-6 text-sm">
|
||||||
|
{t("labels.loading")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive py-6 text-sm">
|
||||||
|
{t("pages.agent.load_error")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("pages.agent.skills.description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{data?.skills.length ? (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{data.skills.map((skill) => (
|
||||||
|
<Card
|
||||||
|
key={`${skill.source}:${skill.name}`}
|
||||||
|
className="border-border/60 gap-4 bg-white/80"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="font-semibold">
|
||||||
|
{skill.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-3">
|
||||||
|
{skill.description ||
|
||||||
|
t("pages.agent.skills.no_description")}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setSelectedSkill(skill)}
|
||||||
|
title={t("pages.agent.skills.view")}
|
||||||
|
>
|
||||||
|
<IconFileInfo className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{skill.source === "workspace" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setSkillPendingDelete(skill)}
|
||||||
|
title={t("pages.agent.skills.delete")}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
|
||||||
|
{t("pages.agent.skills.path")}
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/60 overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
|
||||||
|
{skill.path}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="text-muted-foreground py-10 text-center text-sm">
|
||||||
|
{t("pages.agent.skills.empty")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
open={selectedSkill !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSelectedSkill(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="w-full gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
|
||||||
|
>
|
||||||
|
<SheetHeader className="border-b px-6 py-5">
|
||||||
|
<SheetTitle>
|
||||||
|
{selectedSkill?.name || t("pages.agent.skills.viewer_title")}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{selectedSkill?.description ||
|
||||||
|
t("pages.agent.skills.viewer_description")}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-5">
|
||||||
|
{isSkillDetailLoading ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("pages.agent.skills.loading_detail")}
|
||||||
|
</div>
|
||||||
|
) : skillDetailError ? (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
{t("pages.agent.skills.load_detail_error")}
|
||||||
|
</div>
|
||||||
|
) : selectedSkillDetail ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="prose prose-sm dark:prose-invert prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{selectedSkillDetail.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={skillPendingDelete !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSkillPendingDelete(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("pages.agent.skills.delete_title")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("pages.agent.skills.delete_description", {
|
||||||
|
name: skillPendingDelete?.name,
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteMutation.isPending || !skillPendingDelete}
|
||||||
|
onClick={() => {
|
||||||
|
if (skillPendingDelete)
|
||||||
|
deleteMutation.mutate(skillPendingDelete.name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<IconLoader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
)}
|
||||||
|
{t("pages.agent.skills.delete_confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
web/frontend/src/components/tools/tools-page.tsx
Normal file
190
web/frontend/src/components/tools/tools-page.tsx
Normal file
|
|
@ -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<string, ToolSupportItem[]>()
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title={t("navigation.tools")} />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-3">
|
||||||
|
<div className="w-full max-w-6xl space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-6 text-sm">
|
||||||
|
{t("labels.loading")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive py-6 text-sm">
|
||||||
|
{t("pages.agent.load_error")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-5">
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{t("pages.agent.tools.description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{data?.tools.length ? (
|
||||||
|
groupedTools.map(([category, items]) => (
|
||||||
|
<div key={category} className="space-y-3">
|
||||||
|
<div className="text-foreground/85 text-sm font-semibold tracking-wide">
|
||||||
|
{t(`pages.agent.tools.categories.${category}`)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{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 (
|
||||||
|
<Card
|
||||||
|
key={tool.name}
|
||||||
|
className={cn(
|
||||||
|
"gap-4 border transition-colors",
|
||||||
|
tool.status === "enabled" &&
|
||||||
|
"border-emerald-200/70 bg-emerald-50/50",
|
||||||
|
tool.status === "blocked" &&
|
||||||
|
"border-amber-200/80 bg-amber-50/60",
|
||||||
|
tool.status === "disabled" &&
|
||||||
|
"border-border/60 bg-card/70",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="font-mono text-sm break-all">
|
||||||
|
{tool.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1 break-words">
|
||||||
|
{tool.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||||
|
<ToolStatusBadge status={tool.status} />
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
nextEnabled ? "default" : "outline"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
toggleMutation.mutate({
|
||||||
|
name: tool.name,
|
||||||
|
enabled: nextEnabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<IconLoader2 className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{nextEnabled
|
||||||
|
? t("pages.agent.tools.enable")
|
||||||
|
: t("pages.agent.tools.disable")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("pages.agent.tools.config_key", {
|
||||||
|
key: tool.config_key,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{reasonText ? (
|
||||||
|
<div className="text-sm text-amber-800">
|
||||||
|
{reasonText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="text-muted-foreground py-10 text-center text-sm">
|
||||||
|
{t("pages.agent.tools.empty")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold",
|
||||||
|
status === "enabled" && "bg-emerald-100 text-emerald-700",
|
||||||
|
status === "blocked" && "bg-amber-100 text-amber-700",
|
||||||
|
status === "disabled" && "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`pages.agent.tools.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useAtomValue } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { getPicoToken } from "@/api/pico"
|
import { getPicoToken } from "@/api/pico"
|
||||||
import { getSessionHistory } from "@/api/sessions"
|
import { getSessionHistory } from "@/api/sessions"
|
||||||
|
|
@ -100,6 +102,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePicoChat() {
|
export function usePicoChat() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { status: gatewayState } = useAtomValue(gatewayAtom)
|
const { status: gatewayState } = useAtomValue(gatewayAtom)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [connectionState, setConnectionState] =
|
const [connectionState, setConnectionState] =
|
||||||
|
|
@ -317,43 +320,38 @@ export function usePicoChat() {
|
||||||
// Switch to a historical session
|
// Switch to a historical session
|
||||||
const switchSession = useCallback(
|
const switchSession = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string) => {
|
||||||
// Disconnect current WebSocket
|
if (sessionId === activeSessionIdRef.current) {
|
||||||
disconnect()
|
return
|
||||||
|
}
|
||||||
// Set new session ID
|
|
||||||
setActiveSessionId(sessionId)
|
try {
|
||||||
setIsTyping(false)
|
const detail = await getSessionHistory(sessionId)
|
||||||
|
const fallbackTime = detail.updated
|
||||||
// Load history from backend
|
const historyMessages = detail.messages.map((m, i) => ({
|
||||||
try {
|
id: `hist-${i}-${Date.now()}`,
|
||||||
const detail = await getSessionHistory(sessionId)
|
role: m.role as "user" | "assistant",
|
||||||
// Set all history messages timestamp from the session updated time as fallback,
|
content: m.content,
|
||||||
// since currently the backend doesn't return per-message timestamp in the history API.
|
timestamp: fallbackTime,
|
||||||
// We'll use the session's updated time for now.
|
}))
|
||||||
const fallbackTime = detail.updated
|
|
||||||
|
// Only switch the active websocket session after history has loaded successfully.
|
||||||
setMessages(
|
disconnect()
|
||||||
detail.messages.map((m, i) => ({
|
setActiveSessionId(sessionId)
|
||||||
id: `hist-${i}-${Date.now()}`,
|
setIsTyping(false)
|
||||||
role: m.role as "user" | "assistant",
|
setMessages(historyMessages)
|
||||||
content: m.content,
|
} catch (err) {
|
||||||
timestamp: fallbackTime,
|
console.error("Failed to load session history:", err)
|
||||||
})),
|
toast.error(t("chat.historyOpenFailed"))
|
||||||
)
|
return
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load session history:", err)
|
|
||||||
setMessages([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect with new session ID (will use the updated ref)
|
|
||||||
// Small delay to ensure state has settled
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (gatewayState === "running") {
|
if (gatewayState === "running") {
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
},
|
},
|
||||||
[disconnect, connect, gatewayState],
|
[connect, disconnect, gatewayState, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start a new empty chat
|
// Start a new empty chat
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions"
|
import { type SessionSummary, deleteSession, getSessions } from "@/api/sessions"
|
||||||
|
|
||||||
|
|
@ -13,22 +14,26 @@ export function useSessionHistory({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
onDeletedActiveSession,
|
onDeletedActiveSession,
|
||||||
}: UseSessionHistoryOptions) {
|
}: UseSessionHistoryOptions) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const observerRef = useRef<HTMLDivElement>(null)
|
const observerRef = useRef<HTMLDivElement>(null)
|
||||||
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
|
||||||
const loadSessions = useCallback(
|
const loadSessions = useCallback(
|
||||||
async (reset = true) => {
|
async (reset = true) => {
|
||||||
try {
|
try {
|
||||||
const currentOffset = reset ? 0 : offset
|
const currentOffset = reset ? 0 : offset
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
setLoadError(false)
|
||||||
setHasMore(true)
|
setHasMore(true)
|
||||||
setOffset(0)
|
setOffset(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getSessions(currentOffset, LIMIT)
|
const data = await getSessions(currentOffset, LIMIT)
|
||||||
|
setLoadError(false)
|
||||||
|
|
||||||
if (data.length < LIMIT) {
|
if (data.length < LIMIT) {
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
|
|
@ -45,8 +50,12 @@ export function useSessionHistory({
|
||||||
}
|
}
|
||||||
|
|
||||||
setOffset(currentOffset + data.length)
|
setOffset(currentOffset + data.length)
|
||||||
} catch {
|
} catch (err) {
|
||||||
// silently fail
|
console.error("Failed to fetch session history:", err)
|
||||||
|
setLoadError(true)
|
||||||
|
if (!reset) {
|
||||||
|
setHasMore(false)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMore(false)
|
setIsLoadingMore(false)
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +64,16 @@ export function useSessionHistory({
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!observerRef.current || !hasMore || isLoadingMore) return
|
if (!observerRef.current || !hasMore || isLoadingMore || loadError) return
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
|
if (
|
||||||
|
entries[0].isIntersecting &&
|
||||||
|
hasMore &&
|
||||||
|
!isLoadingMore &&
|
||||||
|
!loadError
|
||||||
|
) {
|
||||||
setIsLoadingMore(true)
|
setIsLoadingMore(true)
|
||||||
void loadSessions(false)
|
void loadSessions(false)
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +83,7 @@ export function useSessionHistory({
|
||||||
|
|
||||||
observer.observe(observerRef.current)
|
observer.observe(observerRef.current)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [hasMore, isLoadingMore, loadSessions])
|
}, [hasMore, isLoadingMore, loadError, loadSessions])
|
||||||
|
|
||||||
const handleDeleteSession = useCallback(
|
const handleDeleteSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
|
@ -89,6 +103,8 @@ export function useSessionHistory({
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
loadError,
|
||||||
|
loadErrorMessage: t("chat.historyLoadFailed"),
|
||||||
observerRef,
|
observerRef,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { gatewayAtom } from "@/store/gateway"
|
||||||
|
|
||||||
const DEFAULT_VISIBLE_CHANNELS = 5
|
const DEFAULT_VISIBLE_CHANNELS = 4
|
||||||
const CHANNEL_IMPORTANCE_ORDER = [
|
const CHANNEL_IMPORTANCE_ORDER = [
|
||||||
"discord",
|
"discord",
|
||||||
"feishu",
|
"feishu",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
"model_group": "Models",
|
"model_group": "Models",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
|
"agent_group": "Agent",
|
||||||
|
"skills": "Skills",
|
||||||
|
"tools": "Tools",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"channels_group": "Channels",
|
"channels_group": "Channels",
|
||||||
"show_more_channels": "More",
|
"show_more_channels": "More",
|
||||||
|
|
@ -25,6 +28,8 @@
|
||||||
},
|
},
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"noHistory": "No chat history yet",
|
"noHistory": "No chat history yet",
|
||||||
|
"historyLoadFailed": "Failed to load chat history",
|
||||||
|
"historyOpenFailed": "Failed to open this chat history",
|
||||||
"loadingMore": "Loading more...",
|
"loadingMore": "Loading more...",
|
||||||
"deleteSession": "Delete session",
|
"deleteSession": "Delete session",
|
||||||
"messagesCount": "{{count}} messages",
|
"messagesCount": "{{count}} messages",
|
||||||
|
|
@ -324,6 +329,100 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"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/<name>/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": {
|
"config": {
|
||||||
"load_error": "Failed to load configuration. Please refresh and try again.",
|
"load_error": "Failed to load configuration. Please refresh and try again.",
|
||||||
"workspace": "Workspace Directory",
|
"workspace": "Workspace Directory",
|
||||||
|
|
@ -387,7 +486,9 @@
|
||||||
"unsaved_changes": "You have unsaved changes."
|
"unsaved_changes": "You have unsaved changes."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "System logs and monitoring."
|
"description": "System logs and monitoring.",
|
||||||
|
"clear": "Clear logs",
|
||||||
|
"empty": "Waiting for logs..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
"model_group": "模型",
|
"model_group": "模型",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"credentials": "凭据",
|
"credentials": "凭据",
|
||||||
|
"agent_group": "智能体",
|
||||||
|
"skills": "技能",
|
||||||
|
"tools": "工具",
|
||||||
"services": "服务",
|
"services": "服务",
|
||||||
"channels_group": "频道",
|
"channels_group": "频道",
|
||||||
"show_more_channels": "更多",
|
"show_more_channels": "更多",
|
||||||
|
|
@ -25,6 +28,8 @@
|
||||||
},
|
},
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"noHistory": "暂无对话历史",
|
"noHistory": "暂无对话历史",
|
||||||
|
"historyLoadFailed": "加载历史记录失败",
|
||||||
|
"historyOpenFailed": "打开该历史会话失败",
|
||||||
"loadingMore": "加载更多...",
|
"loadingMore": "加载更多...",
|
||||||
"deleteSession": "删除会话",
|
"deleteSession": "删除会话",
|
||||||
"messagesCount": "{{count}} 条消息",
|
"messagesCount": "{{count}} 条消息",
|
||||||
|
|
@ -324,6 +329,100 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"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/<name>/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": {
|
"config": {
|
||||||
"load_error": "加载配置失败,请刷新后重试。",
|
"load_error": "加载配置失败,请刷新后重试。",
|
||||||
"workspace": "工作目录",
|
"workspace": "工作目录",
|
||||||
|
|
@ -387,7 +486,9 @@
|
||||||
"unsaved_changes": "您有未保存的更改。"
|
"unsaved_changes": "您有未保存的更改。"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"description": "系统日志和监控。"
|
"description": "系统日志和监控。",
|
||||||
|
"clear": "清空日志",
|
||||||
|
"empty": "等待日志中..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@ import { Route as ModelsRouteImport } from './routes/models'
|
||||||
import { Route as LogsRouteImport } from './routes/logs'
|
import { Route as LogsRouteImport } from './routes/logs'
|
||||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||||
import { Route as ConfigRouteImport } from './routes/config'
|
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 ChannelsRouteRouteImport } from './routes/channels/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
||||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||||
|
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
||||||
|
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
||||||
|
|
||||||
const ModelsRoute = ModelsRouteImport.update({
|
const ModelsRoute = ModelsRouteImport.update({
|
||||||
id: '/models',
|
id: '/models',
|
||||||
|
|
@ -38,6 +41,11 @@ const ConfigRoute = ConfigRouteImport.update({
|
||||||
path: '/config',
|
path: '/config',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentRoute = AgentRouteImport.update({
|
||||||
|
id: '/agent',
|
||||||
|
path: '/agent',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ChannelsRouteRoute = ChannelsRouteRouteImport.update({
|
const ChannelsRouteRoute = ChannelsRouteRouteImport.update({
|
||||||
id: '/channels',
|
id: '/channels',
|
||||||
path: '/channels',
|
path: '/channels',
|
||||||
|
|
@ -58,24 +66,40 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
||||||
path: '/$name',
|
path: '/$name',
|
||||||
getParentRoute: () => ChannelsRouteRoute,
|
getParentRoute: () => ChannelsRouteRoute,
|
||||||
} as any)
|
} 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 {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||||
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||||
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
|
|
@ -83,10 +107,13 @@ export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||||
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
|
|
@ -95,30 +122,39 @@ export interface FileRouteTypes {
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/channels'
|
| '/channels'
|
||||||
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/agent/skills'
|
||||||
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/channels'
|
| '/channels'
|
||||||
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/agent/skills'
|
||||||
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/channels'
|
| '/channels'
|
||||||
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/agent/skills'
|
||||||
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
|
|
@ -126,6 +162,7 @@ export interface FileRouteTypes {
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
||||||
|
AgentRoute: typeof AgentRouteWithChildren
|
||||||
ConfigRoute: typeof ConfigRouteWithChildren
|
ConfigRoute: typeof ConfigRouteWithChildren
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
|
|
@ -162,6 +199,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ConfigRouteImport
|
preLoaderRoute: typeof ConfigRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agent': {
|
||||||
|
id: '/agent'
|
||||||
|
path: '/agent'
|
||||||
|
fullPath: '/agent'
|
||||||
|
preLoaderRoute: typeof AgentRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/channels': {
|
'/channels': {
|
||||||
id: '/channels'
|
id: '/channels'
|
||||||
path: '/channels'
|
path: '/channels'
|
||||||
|
|
@ -190,6 +234,20 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ChannelsNameRouteImport
|
preLoaderRoute: typeof ChannelsNameRouteImport
|
||||||
parentRoute: typeof ChannelsRouteRoute
|
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,
|
ChannelsRouteRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AgentRouteChildren {
|
||||||
|
AgentSkillsRoute: typeof AgentSkillsRoute
|
||||||
|
AgentToolsRoute: typeof AgentToolsRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgentRouteChildren: AgentRouteChildren = {
|
||||||
|
AgentSkillsRoute: AgentSkillsRoute,
|
||||||
|
AgentToolsRoute: AgentToolsRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren)
|
||||||
|
|
||||||
interface ConfigRouteChildren {
|
interface ConfigRouteChildren {
|
||||||
ConfigRawRoute: typeof ConfigRawRoute
|
ConfigRawRoute: typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +289,7 @@ const ConfigRouteWithChildren =
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||||
|
AgentRoute: AgentRouteWithChildren,
|
||||||
ConfigRoute: ConfigRouteWithChildren,
|
ConfigRoute: ConfigRouteWithChildren,
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
LogsRoute: LogsRoute,
|
LogsRoute: LogsRoute,
|
||||||
|
|
|
||||||
22
web/frontend/src/routes/agent.tsx
Normal file
22
web/frontend/src/routes/agent.tsx
Normal file
|
|
@ -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 <Navigate to="/agent/skills" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
11
web/frontend/src/routes/agent/skills.tsx
Normal file
11
web/frontend/src/routes/agent/skills.tsx
Normal file
|
|
@ -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 <SkillsPage />
|
||||||
|
}
|
||||||
11
web/frontend/src/routes/agent/tools.tsx
Normal file
11
web/frontend/src/routes/agent/tools.tsx
Normal file
|
|
@ -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 <ToolsPage />
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
import { IconTrash } from "@tabler/icons-react"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useAtomValue } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { getGatewayStatus } from "@/api/gateway"
|
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { gatewayAtom } from "@/store/gateway"
|
import { gatewayAtom } from "@/store/gateway"
|
||||||
|
|
||||||
|
|
@ -15,12 +17,31 @@ export const Route = createFileRoute("/logs")({
|
||||||
function LogsPage() {
|
function LogsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [logs, setLogs] = useState<string[]>([])
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
|
const [clearing, setClearing] = useState(false)
|
||||||
const logOffsetRef = useRef<number>(0)
|
const logOffsetRef = useRef<number>(0)
|
||||||
const logRunIdRef = useRef<number>(-1)
|
const logRunIdRef = useRef<number>(-1)
|
||||||
|
const syncTokenRef = useRef<number>(0)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const gateway = useAtomValue(gatewayAtom)
|
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(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
let mounted = true
|
||||||
let timeout: ReturnType<typeof setTimeout>
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
|
@ -40,17 +61,17 @@ function LogsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestToken = syncTokenRef.current
|
||||||
|
const requestOffset = logOffsetRef.current
|
||||||
|
const requestRunId = logRunIdRef.current
|
||||||
const data = await getGatewayStatus({
|
const data = await getGatewayStatus({
|
||||||
log_offset: logOffsetRef.current,
|
log_offset: requestOffset,
|
||||||
log_run_id: logRunIdRef.current,
|
log_run_id: requestRunId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!mounted) return
|
if (!mounted || requestToken !== syncTokenRef.current) return
|
||||||
|
|
||||||
if (
|
if (data.log_run_id !== undefined && data.log_run_id !== requestRunId) {
|
||||||
data.log_run_id !== undefined &&
|
|
||||||
data.log_run_id !== logRunIdRef.current
|
|
||||||
) {
|
|
||||||
logRunIdRef.current = data.log_run_id
|
logRunIdRef.current = data.log_run_id
|
||||||
logOffsetRef.current = 0
|
logOffsetRef.current = 0
|
||||||
if (data.logs) {
|
if (data.logs) {
|
||||||
|
|
@ -90,13 +111,25 @@ function LogsPage() {
|
||||||
<PageHeader title={t("navigation.logs")} />
|
<PageHeader title={t("navigation.logs")} />
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
|
<div className="flex flex-1 flex-col overflow-hidden p-4 sm:p-8">
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex items-start justify-between gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<div>
|
||||||
{t("navigation.logs")}
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
</h1>
|
{t("navigation.logs")}
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
</h1>
|
||||||
{t("pages.logs.description")}
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
</p>
|
{t("pages.logs.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearLogs}
|
||||||
|
disabled={logs.length === 0 || clearing}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
{t("pages.logs.clear")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/30 relative flex-1 overflow-hidden rounded-lg border">
|
<div className="bg-muted/30 relative flex-1 overflow-hidden rounded-lg border">
|
||||||
|
|
@ -104,7 +137,7 @@ function LogsPage() {
|
||||||
<div className="p-4 font-mono text-sm leading-relaxed">
|
<div className="p-4 font-mono text-sm leading-relaxed">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-muted-foreground italic">
|
<div className="text-muted-foreground italic">
|
||||||
Waiting for logs...
|
{t("pages.logs.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, i) => (
|
logs.map((log, i) => (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue