Merge branch 'sipeed:main' into main

This commit is contained in:
pixiaoka 2026-03-13 21:54:47 +08:00 committed by GitHub
commit 684accb6a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1797 additions and 647 deletions

View file

@ -65,6 +65,7 @@ builds:
- windows - windows
- darwin - darwin
- freebsd - freebsd
- netbsd
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
@ -82,6 +83,12 @@ builds:
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
- id: picoclaw-launcher-tui - id: picoclaw-launcher-tui
binary: picoclaw-launcher-tui binary: picoclaw-launcher-tui
@ -96,6 +103,7 @@ builds:
- windows - windows
- darwin - darwin
- freebsd - freebsd
- netbsd
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
@ -113,6 +121,12 @@ builds:
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
dockers_v2: dockers_v2:
- id: picoclaw - id: picoclaw

View file

@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/chzyer/readline" "github.com/ergochat/readline"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"

6
go.mod
View file

@ -7,11 +7,11 @@ require (
github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/anthropics/anthropic-sdk-go v1.22.1
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1
github.com/ergochat/irc-go v0.5.0 github.com/ergochat/irc-go v0.5.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3
@ -30,6 +30,7 @@ require (
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.3 maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1 modernc.org/sqlite v1.46.1
) )
@ -60,7 +61,6 @@ require (
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/term v0.40.0 // indirect golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

9
go.sum
View file

@ -27,12 +27,6 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@ -50,6 +44,8 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
@ -297,7 +293,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -63,6 +63,22 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
return nil return nil
} }
if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 {
logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil)
return nil
}
findValidServer := false
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
if serverCfg.Enabled {
findValidServer = true
}
}
if !findValidServer {
logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil)
return nil
}
al.mcp.initOnce.Do(func() { al.mcp.initOnce.Do(func() {
mcpManager := mcp.NewManager() mcpManager := mcp.NewManager()

View file

@ -770,13 +770,18 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
} }
} }
func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) { // TestProcessDirectWithChannel_TriggersMCPInitialization verifies that
// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled.
// Note: Manager is only initialized when at least one MCP server is configured
// and successfully connected.
func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
// Test with MCP enabled but no servers - should not initialize manager
cfg := &config.Config{ cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
@ -791,6 +796,7 @@ func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
ToolConfig: config.ToolConfig{ ToolConfig: config.ToolConfig{
Enabled: true, Enabled: true,
}, },
// No servers configured - manager should not be initialized
}, },
}, },
} }
@ -815,8 +821,9 @@ func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
if !al.mcp.hasManager() { // Manager should not be initialized when no servers are configured
t.Fatal("expected MCP manager to be initialized in direct agent mode") if al.mcp.hasManager() {
t.Fatal("expected MCP manager to be nil when no servers are configured")
} }
} }

View file

@ -373,9 +373,37 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "" return ""
} }
matches := absolutePathPattern.FindAllString(cmd, -1) // Web URL schemes whose path components (starting with //) should be exempt
// from workspace sandbox checks. file: is intentionally excluded so that
// file:// URIs are still validated against the workspace boundary.
webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"}
matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1)
for _, loc := range matchIndices {
raw := cmd[loc[0]:loc[1]]
// Skip URL path components that look like they're from web URLs.
// When a URL like "https://github.com" is parsed, the regex captures
// "//github.com" as a match (the path portion after "https:").
// Use the exact match position (loc[0]) so that duplicate //path substrings
// in the same command are each evaluated at their own position.
if strings.HasPrefix(raw, "//") && loc[0] > 0 {
before := cmd[:loc[0]]
isWebURL := false
for _, scheme := range webSchemes {
if strings.HasSuffix(before, scheme) {
isWebURL = true
break
}
}
if isWebURL {
continue
}
}
for _, raw := range matches {
p, err := filepath.Abs(raw) p, err := filepath.Abs(raw)
if err != nil { if err != nil {
continue continue

View file

@ -522,3 +522,101 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) {
t.Errorf("'git push upstream main' should still be blocked by deny pattern") t.Errorf("'git push upstream main' should still be blocked by deny pattern")
} }
} }
// TestShellTool_URLsNotBlocked verifies that commands containing URLs are not
// incorrectly blocked by the workspace restriction safety guard (issue #1203).
func TestShellTool_URLsNotBlocked(t *testing.T) {
tmpDir := t.TempDir()
tool, err := NewExecTool(tmpDir, true)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
// These commands contain URLs and should NOT be blocked by workspace restriction.
// The URL path components (e.g., "//github.com") should be recognized as URLs,
// not as file system paths.
commands := []string{
"agent-browser open https://github.com",
"curl https://api.example.com/data",
"wget http://example.com/file",
"browser open https://github.com/user/repo",
"fetch ftp://ftp.example.com/file.txt",
"git clone https://github.com/sipeed/picoclaw.git",
}
for _, cmd := range commands {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM)
}
}
}
// TestShellTool_FileURISandboxing verifies that file:// URIs that escape the
// workspace are still blocked, even though other URLs are allowed (issue #1254).
func TestShellTool_FileURISandboxing(t *testing.T) {
tmpDir := t.TempDir()
tool, err := NewExecTool(tmpDir, true)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
// These file:// URIs should be blocked if they reference paths outside the workspace.
// Unlike web URLs (http://, https://, ftp://), file:// URIs can be used to escape the sandbox.
blockedCommands := []string{
"cat file:///etc/passwd",
"cat file:///etc/hosts",
"cat file:///root/.ssh/id_rsa",
}
for _, cmd := range blockedCommands {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI outside workspace should be blocked: %s", cmd)
}
}
// These file:// URIs should be allowed if they reference paths inside the workspace.
// Create a test file inside the temp directory
testFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil {
t.Fatalf("failed to create test file: %s", err)
}
allowedCommands := []string{
"cat file://" + testFile,
}
for _, cmd := range allowedCommands {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM)
}
}
}
// TestShellTool_URLBypassPrevented verifies that a command cannot bypass the workspace
// sandbox by smuggling a real path after a URL that contains the same //path substring.
// e.g. "echo https://etc/passwd && cat //etc/passwd" must still be blocked.
func TestShellTool_URLBypassPrevented(t *testing.T) {
tmpDir := t.TempDir()
tool, err := NewExecTool(tmpDir, true)
if err != nil {
t.Fatalf("unable to configure exec tool: %s", err)
}
// The path //etc/passwd appears twice: once as the host part of an https URL
// and once as a real (escaped) absolute path. The guard must block the command
// because the second occurrence is a genuine out-of-workspace path.
blockedCommands := []string{
"echo https://etc/passwd && cat //etc/passwd",
"curl https://host/file && ls //etc",
}
for _, cmd := range blockedCommands {
result := tool.Execute(context.Background(), map[string]any{"command": cmd})
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM)
}
}
}

View file

@ -7,8 +7,11 @@ import (
// GatewayEvent represents a state change event for the gateway process. // GatewayEvent represents a state change event for the gateway process.
type GatewayEvent struct { type GatewayEvent struct {
Status string `json:"gateway_status"` // "running", "starting", "stopped", "error" Status string `json:"gateway_status"` // "running", "starting", "restarting", "stopped", "error"
PID int `json:"pid,omitempty"` PID int `json:"pid,omitempty"`
BootDefaultModel string `json:"boot_default_model,omitempty"`
ConfigDefaultModel string `json:"config_default_model,omitempty"`
RestartRequired bool `json:"gateway_restart_required,omitempty"`
} }
// EventBroadcaster manages SSE client subscriptions and broadcasts events. // EventBroadcaster manages SSE client subscriptions and broadcasts events.

View file

@ -23,19 +23,36 @@ import (
// gateway holds the state for the managed gateway process. // gateway holds the state for the managed gateway process.
var gateway = struct { var gateway = struct {
mu sync.Mutex mu sync.Mutex
cmd *exec.Cmd cmd *exec.Cmd
logs *LogBuffer bootDefaultModel string
events *EventBroadcaster runtimeStatus string
startupDeadline time.Time
logs *LogBuffer
events *EventBroadcaster
}{ }{
logs: NewLogBuffer(200), runtimeStatus: "stopped",
events: NewEventBroadcaster(), logs: NewLogBuffer(200),
events: NewEventBroadcaster(),
}
var (
gatewayStartupWindow = 15 * time.Second
gatewayRestartGracePeriod = 5 * time.Second
gatewayRestartForceKillWindow = 3 * time.Second
gatewayRestartPollInterval = 100 * time.Millisecond
)
var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
client := http.Client{Timeout: timeout}
return client.Get(url)
} }
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux. // registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
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("GET /api/gateway/logs", h.handleGatewayLogs)
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) 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)
@ -65,7 +82,7 @@ func (h *Handler) TryAutoStartGateway() {
return return
} }
pid, err := h.startGatewayLocked() pid, err := h.startGatewayLocked("starting")
if err != nil { if err != nil {
log.Printf("Failed to auto-start gateway: %v", err) log.Printf("Failed to auto-start gateway: %v", err)
return return
@ -131,7 +148,110 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
return cmd.Process.Signal(syscall.Signal(0)) == nil return cmd.Process.Signal(syscall.Signal(0)) == nil
} }
func (h *Handler) startGatewayLocked() (int, error) { func setGatewayRuntimeStatusLocked(status string) {
gateway.runtimeStatus = status
if status == "starting" || status == "restarting" {
gateway.startupDeadline = time.Now().Add(gatewayStartupWindow)
return
}
gateway.startupDeadline = time.Time{}
}
func gatewayStatusOnHealthFailureLocked() string {
if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" {
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
return gateway.runtimeStatus
}
return "error"
}
if gateway.runtimeStatus == "running" {
return "running"
}
if gateway.runtimeStatus == "error" {
return "error"
}
return "error"
}
func currentGatewayStatusLocked(processAlive bool) string {
if !processAlive {
if gateway.runtimeStatus == "restarting" {
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
return "restarting"
}
return "error"
}
if gateway.runtimeStatus == "error" {
return "error"
}
return "stopped"
}
return gatewayStatusOnHealthFailureLocked()
}
func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {
if cmd == nil || cmd.Process == nil {
return true
}
deadline := time.Now().Add(timeout)
for {
if !isCmdProcessAliveLocked(cmd) {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(gatewayRestartPollInterval)
}
}
func stopGatewayProcessForRestart(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) {
return nil
}
var stopErr error
if runtime.GOOS == "windows" {
stopErr = cmd.Process.Kill()
} else {
stopErr = cmd.Process.Signal(syscall.SIGTERM)
}
if stopErr != nil && isCmdProcessAliveLocked(cmd) {
return fmt.Errorf("failed to stop existing gateway: %w", stopErr)
}
if waitForGatewayProcessExit(cmd, gatewayRestartGracePeriod) {
return nil
}
if runtime.GOOS != "windows" {
killErr := cmd.Process.Signal(syscall.SIGKILL)
if killErr != nil && isCmdProcessAliveLocked(cmd) {
return fmt.Errorf("failed to force-stop existing gateway: %w", killErr)
}
if waitForGatewayProcessExit(cmd, gatewayRestartForceKillWindow) {
return nil
}
}
return fmt.Errorf("existing gateway did not exit before restart")
}
func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool {
return status == "running" &&
bootDefaultModel != "" &&
configDefaultModel != "" &&
bootDefaultModel != configDefaultModel
}
func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return 0, fmt.Errorf("failed to load config: %w", err)
}
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
// Locate the picoclaw executable // Locate the picoclaw executable
execPath := utils.FindPicoclawBinary() execPath := utils.FindPicoclawBinary()
@ -171,11 +291,19 @@ func (h *Handler) startGatewayLocked() (int, error) {
} }
gateway.cmd = cmd gateway.cmd = cmd
gateway.bootDefaultModel = defaultModelName
setGatewayRuntimeStatusLocked(initialStatus)
pid := cmd.Process.Pid pid := cmd.Process.Pid
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath) log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
// Broadcast starting event // Broadcast the launch state immediately so clients can reflect it without polling.
gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid}) gateway.events.Broadcast(GatewayEvent{
Status: initialStatus,
PID: pid,
BootDefaultModel: defaultModelName,
ConfigDefaultModel: defaultModelName,
RestartRequired: false,
})
// Capture stdout/stderr in background // Capture stdout/stderr in background
go scanPipe(stdoutPipe, gateway.logs) go scanPipe(stdoutPipe, gateway.logs)
@ -190,13 +318,23 @@ func (h *Handler) startGatewayLocked() (int, error) {
} }
gateway.mu.Lock() gateway.mu.Lock()
shouldBroadcastStopped := false
if gateway.cmd == cmd { if gateway.cmd == cmd {
gateway.cmd = nil gateway.cmd = nil
gateway.bootDefaultModel = ""
if gateway.runtimeStatus != "restarting" {
setGatewayRuntimeStatusLocked("stopped")
shouldBroadcastStopped = true
}
} }
gateway.mu.Unlock() gateway.mu.Unlock()
// Broadcast stopped event if shouldBroadcastStopped {
gateway.events.Broadcast(GatewayEvent{Status: "stopped"}) gateway.events.Broadcast(GatewayEvent{
Status: "stopped",
RestartRequired: false,
})
}
}() }()
// Start a goroutine to probe health and broadcast "running" once ready // Start a goroutine to probe health and broadcast "running" once ready
@ -219,12 +357,22 @@ func (h *Handler) startGatewayLocked() (int, error) {
healthPort = 18790 healthPort = 18790
} }
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort))) healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
client := http.Client{Timeout: 1 * time.Second} resp, err := gatewayHealthGet(healthURL, 1*time.Second)
resp, err := client.Get(healthURL)
if err == nil { if err == nil {
resp.Body.Close() resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid}) gateway.mu.Lock()
if gateway.cmd == cmd {
setGatewayRuntimeStatusLocked("running")
}
gateway.mu.Unlock()
gateway.events.Broadcast(GatewayEvent{
Status: "running",
PID: pid,
BootDefaultModel: defaultModelName,
ConfigDefaultModel: defaultModelName,
RestartRequired: false,
})
return return
} }
} }
@ -253,6 +401,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
} }
if gateway.cmd != nil && gateway.cmd.Process != nil { if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil gateway.cmd = nil
setGatewayRuntimeStatusLocked("stopped")
} }
ready, reason, err := h.gatewayStartReady() ready, reason, err := h.gatewayStartReady()
@ -274,7 +423,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
return return
} }
pid, err := h.startGatewayLocked() pid, err := h.startGatewayLocked("starting")
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return return
@ -330,30 +479,72 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
// //
// POST /api/gateway/restart // POST /api/gateway/restart
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock() ready, reason, err := h.gatewayStartReady()
if err != nil {
// Stop existing process if running http.Error(
if gateway.cmd != nil && gateway.cmd.Process != nil { w,
if isCmdProcessAliveLocked(gateway.cmd) { fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
// Process is alive, send SIGTERM http.StatusInternalServerError,
if runtime.GOOS == "windows" { )
gateway.cmd.Process.Kill() return
} else { }
gateway.cmd.Process.Signal(syscall.SIGTERM) if !ready {
} w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
// Wait briefly for it to exit json.NewEncoder(w).Encode(map[string]any{
gateway.mu.Unlock() "status": "precondition_failed",
time.Sleep(2 * time.Second) "message": reason,
gateway.mu.Lock() })
} return
gateway.cmd = nil
} }
gateway.mu.Lock()
previousCmd := gateway.cmd
setGatewayRuntimeStatusLocked("restarting")
gateway.events.Broadcast(GatewayEvent{
Status: "restarting",
RestartRequired: false,
})
gateway.mu.Unlock() gateway.mu.Unlock()
// Start fresh via the existing handler if err = stopGatewayProcessForRestart(previousCmd); err != nil {
h.handleGatewayStart(w, r) gateway.mu.Lock()
if gateway.cmd == previousCmd {
if isCmdProcessAliveLocked(previousCmd) {
setGatewayRuntimeStatusLocked("running")
} else {
gateway.cmd = nil
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("error")
}
}
gateway.mu.Unlock()
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
return
}
gateway.mu.Lock()
if gateway.cmd == previousCmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
}
pid, err := h.startGatewayLocked("restarting")
if err != nil {
gateway.cmd = nil
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("error")
}
gateway.mu.Unlock()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": pid,
})
} }
// handleGatewayClearLogs clears the in-memory gateway log buffer. // handleGatewayClearLogs clears the in-memory gateway log buffer.
@ -370,28 +561,48 @@ func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request)
}) })
} }
// handleGatewayStatus returns the gateway run status, health info, and logs. // handleGatewayStatus returns the gateway run status and health info.
// //
// GET /api/gateway/status // GET /api/gateway/status
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
data := h.gatewayStatusData()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func (h *Handler) gatewayStatusData() map[string]any {
data := map[string]any{} data := map[string]any{}
cfg, cfgErr := config.LoadConfig(h.configPath)
configDefaultModel := ""
if cfgErr == nil && cfg != nil {
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if configDefaultModel != "" {
data["config_default_model"] = configDefaultModel
}
}
// Check process state // Check process state
gateway.mu.Lock() gateway.mu.Lock()
processAlive := isGatewayProcessAliveLocked() processAlive := isGatewayProcessAliveLocked()
bootDefaultModel := ""
if processAlive { if processAlive {
data["pid"] = gateway.cmd.Process.Pid data["pid"] = gateway.cmd.Process.Pid
if gateway.bootDefaultModel != "" {
data["boot_default_model"] = gateway.bootDefaultModel
bootDefaultModel = gateway.bootDefaultModel
}
} }
gateway.mu.Unlock() gateway.mu.Unlock()
if !processAlive { if !processAlive {
data["gateway_status"] = "stopped" gateway.mu.Lock()
data["gateway_status"] = currentGatewayStatusLocked(false)
gateway.mu.Unlock()
} else { } else {
// Process is alive — probe its health endpoint // Process is alive — probe its health endpoint
cfg, err := config.LoadConfig(h.configPath)
host := "127.0.0.1" host := "127.0.0.1"
port := 18790 port := 18790
if err == nil && cfg != nil { if cfgErr == nil && cfg != nil {
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
if cfg.Gateway.Port != 0 { if cfg.Gateway.Port != 0 {
port = cfg.Gateway.Port port = cfg.Gateway.Port
@ -399,21 +610,31 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
} }
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
client := http.Client{Timeout: 2 * time.Second} resp, err := gatewayHealthGet(url, 2*time.Second)
resp, err := client.Get(url)
if err != nil { if err != nil {
data["gateway_status"] = "starting" gateway.mu.Lock()
data["gateway_status"] = currentGatewayStatusLocked(true)
gateway.mu.Unlock()
} else { } else {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("error")
gateway.mu.Unlock()
data["gateway_status"] = "error" data["gateway_status"] = "error"
data["status_code"] = resp.StatusCode data["status_code"] = resp.StatusCode
} else { } else {
var healthData map[string]any var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("error")
gateway.mu.Unlock()
data["gateway_status"] = "error" data["gateway_status"] = "error"
} else { } else {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
for k, v := range healthData { for k, v := range healthData {
data[k] = v data[k] = v
} }
@ -423,6 +644,13 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
} }
} }
status, _ := data["gateway_status"].(string)
data["gateway_restart_required"] = gatewayRestartRequired(
status,
bootDefaultModel,
configDefaultModel,
)
ready, reason, readyErr := h.gatewayStartReady() ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil { if readyErr != nil {
data["gateway_start_allowed"] = false data["gateway_start_allowed"] = false
@ -434,16 +662,22 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
} }
} }
// Append incremental log data return data
appendGatewayLogs(r, data) }
// handleGatewayLogs returns buffered gateway logs, optionally incrementally.
//
// GET /api/gateway/logs
func (h *Handler) handleGatewayLogs(w http.ResponseWriter, r *http.Request) {
data := gatewayLogsData(r)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
// appendGatewayLogs reads log_offset and log_run_id query params from the request // gatewayLogsData reads log_offset and log_run_id query params from the request
// and populates the response data map with incremental log lines. // and returns incremental log lines.
func appendGatewayLogs(r *http.Request, data map[string]any) { func gatewayLogsData(r *http.Request) map[string]any {
data := map[string]any{}
clientOffset := 0 clientOffset := 0
clientRunID := -1 clientRunID := -1
@ -465,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
data["logs"] = []string{} data["logs"] = []string{}
data["log_total"] = 0 data["log_total"] = 0
data["log_run_id"] = 0 data["log_run_id"] = 0
return return data
} }
// If runID changed, reset offset to get all logs from new run // If runID changed, reset offset to get all logs from new run
@ -482,6 +716,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
data["logs"] = lines data["logs"] = lines
data["log_total"] = total data["log_total"] = total
data["log_run_id"] = runID data["log_run_id"] = runID
return data
} }
// handleGatewayEvents serves an SSE stream of gateway state change events. // handleGatewayEvents serves an SSE stream of gateway state change events.
@ -524,28 +759,7 @@ func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
// currentGatewayStatus returns the current gateway status as a JSON string. // currentGatewayStatus returns the current gateway status as a JSON string.
func (h *Handler) currentGatewayStatus() string { func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock() data := h.gatewayStatusData()
defer gateway.mu.Unlock()
data := map[string]any{
"gateway_status": "stopped",
}
if isGatewayProcessAliveLocked() {
data["gateway_status"] = "running"
data["pid"] = gateway.cmd.Process.Pid
}
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
encoded, _ := json.Marshal(data) encoded, _ := json.Marshal(data)
return string(encoded) return string(encoded)
} }

View file

@ -2,19 +2,76 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/auth" "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" "github.com/sipeed/picoclaw/web/backend/utils"
) )
func startLongRunningProcess(t *testing.T) *exec.Cmd {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 30")
} else {
cmd = exec.Command("sleep", "30")
}
if err := cmd.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
return cmd
}
func startIgnoringTermProcess(t *testing.T) *exec.Cmd {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("TERM handling differs on Windows")
}
cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30")
if err := cmd.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
return cmd
}
func resetGatewayTestState(t *testing.T) {
t.Helper()
originalHealthGet := gatewayHealthGet
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
gatewayRestartGracePeriod = originalRestartGracePeriod
gatewayRestartForceKillWindow = originalRestartForceKillWindow
gatewayRestartPollInterval = originalRestartPollInterval
gateway.mu.Lock()
gateway.cmd = nil
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
})
}
func TestGatewayStartReady_NoDefaultModel(t *testing.T) { func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath) h := NewHandler(configPath)
@ -317,6 +374,412 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
} }
} }
func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
// Simulate a process that has already reached the running state.
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return nil, errors.New("probe failed")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
}
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
setGatewayRuntimeStatusLocked("starting")
gateway.startupDeadline = time.Now().Add(-time.Second)
gateway.mu.Unlock()
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return nil, errors.New("probe failed")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "error" {
t.Fatalf("gateway_status = %#v, want %q", got, "error")
}
}
func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("restarting")
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "restarting" {
t.Fatalf("gateway_status = %#v, want %q", got, "restarting")
}
}
func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "previous-model"
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.WriteString(`{"ok":true}`)
return rec.Result(), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_restart_required"]; got != true {
t.Fatalf("gateway_restart_required = %#v, want true", got)
}
}
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = ""
cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startLongRunningProcess(t)
t.Cleanup(func() {
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
}
gateway.mu.Unlock()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
gateway.mu.Lock()
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
gateway.mu.Unlock()
if !stillRunning {
t.Fatalf("gateway process was stopped when restart preconditions failed")
}
}
func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
cmd := startIgnoringTermProcess(t)
t.Cleanup(func() {
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.cmd = nil
gateway.bootDefaultModel = ""
}
gateway.mu.Unlock()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
gatewayRestartGracePeriod = 150 * time.Millisecond
gatewayRestartForceKillWindow = 150 * time.Millisecond
gatewayRestartPollInterval = 10 * time.Millisecond
gateway.mu.Lock()
gateway.cmd = cmd
gateway.bootDefaultModel = "existing-model"
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
gateway.mu.Lock()
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
status := gateway.runtimeStatus
gateway.mu.Unlock()
if !stillRunning {
t.Fatalf("gateway process was replaced before the old process exited")
}
if status != "running" {
t.Fatalf("runtimeStatus = %q, want %q", status, "running")
}
}
func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
invalidBinaryPath := filepath.Join(t.TempDir(), "fake-picoclaw")
if err := os.WriteFile(invalidBinaryPath, []byte("#!/bin/sh\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
t.Setenv("PICOCLAW_BINARY", invalidBinaryPath)
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("restart status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
statusRec := httptest.NewRecorder()
statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", statusRec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "error" {
t.Fatalf("gateway_status = %#v, want %q", got, "error")
}
}
func TestGatewayStatusExcludesLogsFields(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if _, ok := body["logs"]; ok {
t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"])
}
if _, ok := body["log_total"]; ok {
t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"])
}
if _, ok := body["log_run_id"]; ok {
t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"])
}
}
func TestGatewayLogsReturnsIncrementalHistory(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")
runID := gateway.logs.RunID()
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
"/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID),
nil,
)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal logs response: %v", err)
}
logs, ok := body["logs"].([]any)
if !ok {
t.Fatalf("logs missing or not array: %#v", body["logs"])
}
if len(logs) != 1 || logs[0] != "second line" {
t.Fatalf("logs = %#v, want [\"second line\"]", logs)
}
if got := body["log_total"]; got != float64(2) {
t.Fatalf("log_total = %#v, want 2", got)
}
if got := body["log_run_id"]; got != float64(runID) {
t.Fatalf("log_run_id = %#v, want %d", got, runID)
}
}
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json") configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath) h := NewHandler(configPath)
@ -353,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
} }
statusRec := httptest.NewRecorder() logsRec := httptest.NewRecorder()
statusReq := httptest.NewRequest( logsReq := httptest.NewRequest(
http.MethodGet, http.MethodGet,
"/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), "/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
nil, nil,
) )
mux.ServeHTTP(statusRec, statusReq) mux.ServeHTTP(logsRec, logsReq)
if statusRec.Code != http.StatusOK { if logsRec.Code != http.StatusOK {
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK) t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
} }
var statusBody map[string]any var logsBody map[string]any
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil { if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
t.Fatalf("unmarshal status response: %v", err) t.Fatalf("unmarshal logs response: %v", err)
} }
logs, ok := statusBody["logs"].([]any) logs, ok := logsBody["logs"].([]any)
if !ok { if !ok {
t.Fatalf("logs missing or not array: %#v", statusBody["logs"]) t.Fatalf("logs missing or not array: %#v", logsBody["logs"])
} }
if len(logs) != 0 { if len(logs) != 0 {
t.Fatalf("logs len = %d, want 0", len(logs)) t.Fatalf("logs len = %d, want 0", len(logs))
} }
if got := statusBody["log_total"]; got != float64(0) { if got := logsBody["log_total"]; got != float64(0) {
t.Fatalf("log_total = %#v, want 0", got) t.Fatalf("log_total = %#v, want 0", got)
} }
if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] {
t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"])
}
} }
func TestFindPicoclawBinary_EnvOverride(t *testing.T) { func TestFindPicoclawBinary_EnvOverride(t *testing.T) {

View file

@ -1,14 +1,20 @@
// API client for gateway process management. // API client for gateway process management.
interface GatewayStatusResponse { interface GatewayStatusResponse {
gateway_status: "running" | "starting" | "stopped" | "error" gateway_status: "running" | "starting" | "restarting" | "stopped" | "error"
gateway_start_allowed?: boolean gateway_start_allowed?: boolean
gateway_start_reason?: string gateway_start_reason?: string
gateway_restart_required?: boolean
pid?: number pid?: number
boot_default_model?: string
config_default_model?: string
[key: string]: unknown
}
interface GatewayLogsResponse {
logs?: string[] logs?: string[]
log_total?: number log_total?: number
log_run_id?: number log_run_id?: number
[key: string]: unknown
} }
interface GatewayActionResponse { interface GatewayActionResponse {
@ -28,10 +34,14 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
export async function getGatewayStatus(options?: { export async function getGatewayStatus(): Promise<GatewayStatusResponse> {
return request<GatewayStatusResponse>("/api/gateway/status")
}
export async function getGatewayLogs(options?: {
log_offset?: number log_offset?: number
log_run_id?: number log_run_id?: number
}): Promise<GatewayStatusResponse> { }): Promise<GatewayLogsResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (options?.log_offset !== undefined) { if (options?.log_offset !== undefined) {
params.set("log_offset", options.log_offset.toString()) params.set("log_offset", options.log_offset.toString())
@ -40,7 +50,7 @@ export async function getGatewayStatus(options?: {
params.set("log_run_id", options.log_run_id.toString()) params.set("log_run_id", options.log_run_id.toString())
} }
const queryString = params.toString() ? `?${params.toString()}` : "" const queryString = params.toString() ? `?${params.toString()}` : ""
return request<GatewayStatusResponse>(`/api/gateway/status${queryString}`) return request<GatewayLogsResponse>(`/api/gateway/logs${queryString}`)
} }
export async function startGateway(): Promise<GatewayActionResponse> { export async function startGateway(): Promise<GatewayActionResponse> {
@ -67,4 +77,8 @@ export async function clearGatewayLogs(): Promise<GatewayActionResponse> {
}) })
} }
export type { GatewayStatusResponse, GatewayActionResponse } export type {
GatewayStatusResponse,
GatewayLogsResponse,
GatewayActionResponse,
}

View file

@ -84,7 +84,7 @@ export async function setDefaultModel(
body: JSON.stringify({ model_name: modelName }), body: JSON.stringify({ model_name: modelName }),
}) })
void refreshGatewayState() await refreshGatewayState()
return response return response
} }

View file

@ -6,6 +6,7 @@ import {
IconMoon, IconMoon,
IconPlayerPlay, IconPlayerPlay,
IconPower, IconPower,
IconRefresh,
IconSun, IconSun,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
@ -31,6 +32,11 @@ import {
} from "@/components/ui/dropdown-menu.tsx" } from "@/components/ui/dropdown-menu.tsx"
import { Separator } from "@/components/ui/separator.tsx" import { Separator } from "@/components/ui/separator.tsx"
import { SidebarTrigger } from "@/components/ui/sidebar" import { SidebarTrigger } from "@/components/ui/sidebar"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway.ts" import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts" import { useTheme } from "@/hooks/use-theme.ts"
@ -41,27 +47,35 @@ export function AppHeader() {
state: gwState, state: gwState,
loading: gwLoading, loading: gwLoading,
canStart, canStart,
restartRequired,
start, start,
restart,
stop, stop,
} = useGateway() } = useGateway()
const isRunning = gwState === "running" const isRunning = gwState === "running"
const isStarting = gwState === "starting" const isStarting = gwState === "starting"
const isRestarting = gwState === "restarting"
const isStopped = gwState === "stopped" || gwState === "unknown" const isStopped = gwState === "stopped" || gwState === "unknown"
const showNotConnectedHint = const showNotConnectedHint =
canStart && (gwState === "stopped" || gwState === "error") !isRestarting && canStart && (gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false) const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => { const handleGatewayToggle = () => {
if (gwLoading || (!isRunning && !canStart)) return if (gwLoading || isRestarting || (!isRunning && !canStart)) return
if (isRunning) { if (isRunning) {
setShowStopDialog(true) setShowStopDialog(true)
} else { } else {
start() void start()
} }
} }
const handleGatewayRestart = () => {
if (gwLoading || isRestarting || !restartRequired || !canStart) return
void restart()
}
const confirmStop = () => { const confirmStop = () => {
setShowStopDialog(false) setShowStopDialog(false)
stop() stop()
@ -115,35 +129,67 @@ export function AppHeader() {
</AlertDialog> </AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2"> <div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{restartRequired && (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25"
onClick={handleGatewayRestart}
disabled={gwLoading || isRestarting || !canStart}
aria-label={t("header.gateway.action.restart")}
>
<IconRefresh className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("header.gateway.restartRequired")}
</TooltipContent>
</Tooltip>
)}
{/* Gateway Start/Stop */} {/* Gateway Start/Stop */}
<Button {isRunning ? (
variant={isStarting ? "secondary" : "default"} <Tooltip delayDuration={700}>
size="sm" <TooltipTrigger asChild>
className={`h-8 gap-2 px-3 ${ <Button
isRunning variant="destructive"
? "bg-destructive/10 text-destructive hover:bg-destructive/20" size="icon-sm"
: isStopped className="size-8"
? "bg-green-500 text-white hover:bg-green-600" onClick={handleGatewayToggle}
: "" disabled={gwLoading}
}`} aria-label={t("header.gateway.action.stop")}
onClick={handleGatewayToggle} >
disabled={gwLoading || isStarting || (!isRunning && !canStart)} <IconPower className="h-4 w-4 opacity-80" />
> </Button>
{gwLoading || isStarting ? ( </TooltipTrigger>
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" /> <TooltipContent>{t("header.gateway.action.stop")}</TooltipContent>
) : isRunning ? ( </Tooltip>
<IconPower className="h-4 w-4 opacity-80" /> ) : (
) : ( <Button
<IconPlayerPlay className="h-4 w-4 opacity-80" /> variant={isStarting || isRestarting ? "secondary" : "default"}
)} size="sm"
<span className="text-xs font-semibold"> className={`h-8 gap-2 px-3 ${
{isRunning isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
? t("header.gateway.action.stop") }`}
: isStarting onClick={handleGatewayToggle}
? t("header.gateway.status.starting") disabled={gwLoading || isStarting || isRestarting || !canStart}
: t("header.gateway.action.start")} >
</span> {gwLoading || isStarting || isRestarting ? (
</Button> <IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
)}
<Separator <Separator
className="mx-4 my-2 hidden md:block" className="mx-4 my-2 hidden md:block"

View file

@ -15,11 +15,13 @@ import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway" import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat" import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history" import { useSessionHistory } from "@/hooks/use-session-history"
import { hydrateActiveSession } from "@/lib/pico-chat-controller"
export function ChatPage() { export function ChatPage() {
const { t } = useTranslation() const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true) const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("") const [input, setInput] = useState("")
const { const {
@ -56,14 +58,26 @@ export function ChatPage() {
onDeletedActiveSession: newChat, onDeletedActiveSession: newChat,
}) })
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const syncScrollState = (element: HTMLDivElement) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget const { scrollTop, scrollHeight, clientHeight } = element
setHasScrolled(scrollTop > 0)
setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10)
} }
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
syncScrollState(e.currentTarget)
}
useEffect(() => { useEffect(() => {
if (isAtBottom && scrollRef.current) { void hydrateActiveSession()
scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [])
useEffect(() => {
if (scrollRef.current) {
if (isAtBottom) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
syncScrollState(scrollRef.current)
} }
}, [messages, isTyping, isAtBottom]) }, [messages, isTyping, isAtBottom])
@ -77,6 +91,9 @@ export function ChatPage() {
<div className="bg-background/95 flex h-full flex-col"> <div className="bg-background/95 flex h-full flex-col">
<PageHeader <PageHeader
title={t("navigation.chat")} title={t("navigation.chat")}
className={`transition-shadow ${
hasScrolled ? "shadow-sm" : "shadow-none"
}`}
titleExtra={ titleExtra={
hasConfiguredModels && ( hasConfiguredModels && (
<ModelSelector <ModelSelector
@ -90,7 +107,7 @@ export function ChatPage() {
} }
> >
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
onClick={newChat} onClick={newChat}
className="h-9 gap-2" className="h-9 gap-2"

View file

@ -37,7 +37,7 @@ export function ModelSelector({
> >
<SelectValue placeholder={t("chat.noModel")} /> <SelectValue placeholder={t("chat.noModel")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" align="start">
{apiKeyModels.length > 0 && ( {apiKeyModels.length > 0 && (
<SelectGroup> <SelectGroup>
<SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel> <SelectLabel>{t("chat.modelGroup.apikey")}</SelectLabel>

View file

@ -41,7 +41,7 @@ export function SessionHistoryMenu({
return ( return (
<DropdownMenu onOpenChange={onOpenChange}> <DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 gap-2"> <Button variant="secondary" size="sm" className="h-9 gap-2">
<IconHistory className="size-4" /> <IconHistory className="size-4" />
<span className="hidden sm:inline">{t("chat.history")}</span> <span className="hidden sm:inline">{t("chat.history")}</span>
</Button> </Button>

View file

@ -110,7 +110,7 @@ export function EditModelSheet({
: undefined, : undefined,
thinking_level: form.thinkingLevel || undefined, thinking_level: form.thinkingLevel || undefined,
}) })
if (setAsDefault) { if (setAsDefault && !model.is_default) {
await setDefaultModel(model.model_name) await setDefaultModel(model.model_name)
} }
onSaved() onSaved()

View file

@ -79,6 +79,8 @@ export function ModelsPage() {
}, [fetchModels]) }, [fetchModels])
const handleSetDefault = async (model: ModelInfo) => { const handleSetDefault = async (model: ModelInfo) => {
if (model.is_default) return
setSettingDefaultIndex(model.index) setSettingDefaultIndex(model.index)
try { try {
await setDefaultModel(model.model_name) await setDefaultModel(model.model_name)

View file

@ -2,16 +2,28 @@ import { IconMenu2 } from "@tabler/icons-react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { SidebarTrigger } from "@/components/ui/sidebar" import { SidebarTrigger } from "@/components/ui/sidebar"
import { cn } from "@/lib/utils"
interface PageHeaderProps { interface PageHeaderProps {
title: string title: string
titleExtra?: ReactNode titleExtra?: ReactNode
children?: ReactNode children?: ReactNode
className?: string
} }
export function PageHeader({ title, titleExtra, children }: PageHeaderProps) { export function PageHeader({
title,
titleExtra,
children,
className,
}: PageHeaderProps) {
return ( return (
<div className="flex h-14 shrink-0 items-center justify-between px-6 pt-2"> <div
className={cn(
"z-40 flex h-14 shrink-0 items-center justify-between px-6 pt-2",
className,
)}
>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<SidebarTrigger className="border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5"> <SidebarTrigger className="border-border/60 bg-background text-muted-foreground hover:bg-accent hover:text-foreground hidden h-9 w-9 rounded-lg border sm:flex [&>svg]:size-5">
<IconMenu2 /> <IconMenu2 />

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
@ -20,6 +20,7 @@ function isLocalModel(model: ModelInfo): boolean {
export function useChatModels({ isConnected }: UseChatModelsOptions) { export function useChatModels({ isConnected }: UseChatModelsOptions) {
const [modelList, setModelList] = useState<ModelInfo[]>([]) const [modelList, setModelList] = useState<ModelInfo[]>([])
const [defaultModelName, setDefaultModelName] = useState("") const [defaultModelName, setDefaultModelName] = useState("")
const setDefaultRequestIdRef = useRef(0)
const loadModels = useCallback(async () => { const loadModels = useCallback(async () => {
try { try {
@ -41,17 +42,28 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
return () => clearTimeout(timerId) return () => clearTimeout(timerId)
}, [isConnected, loadModels]) }, [isConnected, loadModels])
const handleSetDefault = useCallback(async (modelName: string) => { const handleSetDefault = useCallback(
try { async (modelName: string) => {
await setDefaultModel(modelName) if (modelName === defaultModelName) return
setDefaultModelName(modelName) const requestId = ++setDefaultRequestIdRef.current
setModelList((prev) =>
prev.map((m) => ({ ...m, is_default: m.model_name === modelName })), try {
) await setDefaultModel(modelName)
} catch (err) { const data = await getModels()
console.error("Failed to set default model:", err) if (requestId !== setDefaultRequestIdRef.current) {
} return
}, []) }
setModelList(data.models)
if (data.models.some((m) => m.model_name === data.default_model)) {
setDefaultModelName(data.default_model)
}
} catch (err) {
console.error("Failed to set default model:", err)
}
},
[defaultModelName],
)
const hasConfiguredModels = useMemo( const hasConfiguredModels = useMemo(
() => modelList.some((m) => m.configured), () => modelList.some((m) => m.configured),

View file

@ -1,7 +1,7 @@
import { useAtomValue } from "jotai" import { useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway" import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway"
import { gatewayAtom } from "@/store/gateway" import { gatewayAtom } from "@/store/gateway"
export function useGatewayLogs() { export function useGatewayLogs() {
@ -37,7 +37,7 @@ export function useGatewayLogs() {
const fetchLogs = async () => { const fetchLogs = async () => {
if ( if (
!mounted || !mounted ||
(gateway.status !== "running" && gateway.status !== "starting") !["running", "starting", "restarting"].includes(gateway.status)
) { ) {
if (mounted) { if (mounted) {
timeout = setTimeout(fetchLogs, 1000) timeout = setTimeout(fetchLogs, 1000)
@ -49,7 +49,7 @@ export function useGatewayLogs() {
const requestToken = syncTokenRef.current const requestToken = syncTokenRef.current
const requestOffset = logOffsetRef.current const requestOffset = logOffsetRef.current
const requestRunId = logRunIdRef.current const requestRunId = logRunIdRef.current
const data = await getGatewayStatus({ const data = await getGatewayLogs({
log_offset: requestOffset, log_offset: requestOffset,
log_run_id: requestRunId, log_run_id: requestRunId,
}) })

View file

@ -1,31 +1,30 @@
import { useAtom } from "jotai" import { useAtomValue } from "jotai"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { import {
type GatewayStatusResponse, type GatewayStatusResponse,
getGatewayStatus, getGatewayStatus,
restartGateway,
startGateway, startGateway,
stopGateway, stopGateway,
} from "@/api/gateway" } from "@/api/gateway"
import { gatewayAtom } from "@/store" import {
applyGatewayStatusToStore,
gatewayAtom,
updateGatewayStore,
} from "@/store"
// Global variable to ensure we only have one SSE connection // Global variable to ensure we only have one SSE connection
let sseInitialized = false let sseInitialized = false
export function useGateway() { export function useGateway() {
const [{ status: state, canStart }, setGateway] = useAtom(gatewayAtom) const gateway = useAtomValue(gatewayAtom)
const { status: state, canStart, restartRequired } = gateway
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const applyGatewayStatus = useCallback( const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => {
(data: GatewayStatusResponse) => { applyGatewayStatusToStore(data)
setGateway((prev) => ({ }, [])
...prev,
status: data.gateway_status ?? "unknown",
canStart: data.gateway_start_allowed ?? true,
}))
},
[setGateway],
)
// Initialize global SSE connection once // Initialize global SSE connection once
useEffect(() => { useEffect(() => {
@ -35,9 +34,10 @@ export function useGateway() {
getGatewayStatus() getGatewayStatus()
.then((data) => applyGatewayStatus(data)) .then((data) => applyGatewayStatus(data))
.catch(() => { .catch(() => {
setGateway({ updateGatewayStore({
status: "unknown", status: "unknown",
canStart: true, canStart: true,
restartRequired: false,
}) })
}) })
@ -59,14 +59,7 @@ export function useGateway() {
data.gateway_status || data.gateway_status ||
typeof data.gateway_start_allowed === "boolean" typeof data.gateway_start_allowed === "boolean"
) { ) {
setGateway((prev) => ({ applyGatewayStatus(data)
...prev,
status: data.gateway_status ?? prev.status,
canStart:
typeof data.gateway_start_allowed === "boolean"
? data.gateway_start_allowed
: prev.canStart,
}))
} }
} catch { } catch {
// ignore // ignore
@ -75,7 +68,9 @@ export function useGateway() {
es.onerror = () => { es.onerror = () => {
// EventSource will auto-reconnect // EventSource will auto-reconnect
setGateway((prev) => ({ ...prev, status: "unknown" })) updateGatewayStore((prev) =>
prev.status === "restarting" ? {} : { status: "unknown" },
)
} }
return () => { return () => {
@ -83,7 +78,7 @@ export function useGateway() {
es.close() es.close()
sseInitialized = false sseInitialized = false
} }
}, [applyGatewayStatus, setGateway]) }, [applyGatewayStatus])
const start = useCallback(async () => { const start = useCallback(async () => {
if (!canStart) return if (!canStart) return
@ -92,19 +87,19 @@ export function useGateway() {
try { try {
await startGateway() await startGateway()
// SSE will push the real state changes, but set optimistic state // SSE will push the real state changes, but set optimistic state
setGateway((prev) => ({ ...prev, status: "starting" })) updateGatewayStore({ status: "starting" })
} catch (err) { } catch (err) {
console.error("Failed to start gateway:", err) console.error("Failed to start gateway:", err)
try { try {
const status = await getGatewayStatus() const status = await getGatewayStatus()
applyGatewayStatus(status) applyGatewayStatus(status)
} catch { } catch {
setGateway((prev) => ({ ...prev, status: "unknown" })) updateGatewayStore({ status: "unknown" })
} }
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [applyGatewayStatus, canStart, setGateway]) }, [applyGatewayStatus, canStart])
const stop = useCallback(async () => { const stop = useCallback(async () => {
setLoading(true) setLoading(true)
@ -117,5 +112,37 @@ export function useGateway() {
} }
}, []) }, [])
return { state, loading, canStart, start, stop } const restart = useCallback(async () => {
if (state !== "running") return
const previousState = state
const previousCanStart = canStart
const previousRestartRequired = restartRequired
setLoading(true)
updateGatewayStore({
status: "restarting",
restartRequired: false,
})
try {
await restartGateway()
} catch (err) {
console.error("Failed to restart gateway:", err)
try {
const status = await getGatewayStatus()
applyGatewayStatus(status)
} catch {
updateGatewayStore({
status: previousState,
canStart: previousCanStart,
restartRequired: previousRestartRequired,
})
}
} finally {
setLoading(false)
}
}, [applyGatewayStatus, canStart, restartRequired, state])
return { state, loading, canStart, restartRequired, start, stop, restart }
} }

View file

@ -1,79 +1,12 @@
import dayjs from "dayjs" import dayjs from "dayjs"
import { useAtomValue } from "jotai" import { useAtomValue } from "jotai"
import { import {
type SetStateAction, newChatSession,
useCallback, sendChatMessage,
useEffect, switchChatSession,
useRef, } from "@/lib/pico-chat-controller"
useState, import { chatAtom } from "@/store/chat"
} from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { getPicoToken } from "@/api/pico"
import { getSessionHistory } from "@/api/sessions"
import { gatewayAtom } from "@/store"
// Pico Protocol message types
interface PicoMessage {
type: string
id?: string
session_id?: string
timestamp?: number | string
payload?: Record<string, unknown>
}
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
timestamp: number | string
}
type ConnectionState = "disconnected" | "connecting" | "connected" | "error"
const LAST_SESSION_STORAGE_KEY = "picoclaw:last-session-id"
function readStoredSessionId(): string {
const value = localStorage.getItem(LAST_SESSION_STORAGE_KEY)?.trim()
return value || ""
}
function writeStoredSessionId(sessionId: string) {
if (sessionId) {
localStorage.setItem(LAST_SESSION_STORAGE_KEY, sessionId)
return
}
localStorage.removeItem(LAST_SESSION_STORAGE_KEY)
}
function generateSessionId(): string {
const webCrypto = globalThis.crypto
if (webCrypto && typeof webCrypto.randomUUID === "function") {
return webCrypto.randomUUID()
}
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
const bytes = new Uint8Array(16)
webCrypto.getRandomValues(bytes)
// RFC4122 v4: set version and variant bits.
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
return (
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
`${hex[4]}${hex[5]}-` +
`${hex[6]}${hex[7]}-` +
`${hex[8]}${hex[9]}-` +
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
)
}
return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
}
const UNIX_MS_THRESHOLD = 1e12 const UNIX_MS_THRESHOLD = 1e12
@ -124,355 +57,16 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
} }
export function usePicoChat() { export function usePicoChat() {
const { t } = useTranslation() const { messages, connectionState, isTyping, activeSessionId } =
const { status: gatewayState } = useAtomValue(gatewayAtom) useAtomValue(chatAtom)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected")
const [isTyping, setIsTyping] = useState(false)
const [activeSessionId, setActiveSessionId] =
useState<string>(() => readStoredSessionId() || generateSessionId())
const wsRef = useRef<WebSocket | null>(null)
const isConnectingRef = useRef(false)
const msgIdCounter = useRef(0)
const activeSessionIdRef = useRef(activeSessionId)
const messagesRevisionRef = useRef(0)
const setTrackedMessages = useCallback(
(nextState: SetStateAction<ChatMessage[]>) => {
setMessages((prev) => {
const next =
typeof nextState === "function"
? (
nextState as (prevState: ChatMessage[]) => ChatMessage[]
)(prev)
: nextState
if (next !== prev) {
messagesRevisionRef.current += 1
}
return next
})
},
[],
)
// Keep ref in sync
useEffect(() => {
activeSessionIdRef.current = activeSessionId
writeStoredSessionId(activeSessionId)
}, [activeSessionId])
const loadSessionMessages = useCallback(async (sessionId: string) => {
const detail = await getSessionHistory(sessionId)
const fallbackTime = detail.updated
return detail.messages.map((m, i) => ({
id: `hist-${i}-${Date.now()}`,
role: m.role as "user" | "assistant",
content: m.content,
timestamp: fallbackTime,
}))
}, [])
useEffect(() => {
const storedSessionId = readStoredSessionId()
if (!storedSessionId) {
return
}
const restoreRevision = messagesRevisionRef.current
let cancelled = false
void loadSessionMessages(storedSessionId)
.then((historyMessages) => {
if (cancelled) {
return
}
if (activeSessionIdRef.current !== storedSessionId) {
return
}
if (messagesRevisionRef.current !== restoreRevision) {
return
}
setTrackedMessages(historyMessages)
setIsTyping(false)
})
.catch((err) => {
console.error("Failed to restore last session history:", err)
if (cancelled) {
return
}
if (activeSessionIdRef.current !== storedSessionId) {
return
}
if (messagesRevisionRef.current !== restoreRevision) {
return
}
localStorage.removeItem(LAST_SESSION_STORAGE_KEY)
setTrackedMessages([])
setIsTyping(false)
})
return () => {
cancelled = true
}
}, [loadSessionMessages, setTrackedMessages])
const handlePicoMessage = useCallback((msg: PicoMessage) => {
const payload = msg.payload || {}
switch (msg.type) {
case "message.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
// Use provided timestamp or current time
const timestampRaw =
msg.timestamp !== undefined && Number.isFinite(Number(msg.timestamp))
? normalizeUnixTimestamp(Number(msg.timestamp))
: Date.now()
setTrackedMessages((prev) => [
...prev,
{
id: messageId,
role: "assistant",
content,
timestamp: timestampRaw,
},
])
setIsTyping(false)
break
}
case "message.update": {
const content = (payload.content as string) || ""
const messageId = payload.message_id as string
if (!messageId) break
setTrackedMessages((prev) =>
prev.map((m) => (m.id === messageId ? { ...m, content } : m)),
)
break
}
case "typing.start":
setIsTyping(true)
break
case "typing.stop":
setIsTyping(false)
break
case "error":
console.error("Pico error:", payload)
setIsTyping(false)
break
case "pong":
// heartbeat response, ignore
break
default:
console.log("Unknown pico message type:", msg.type)
}
}, [setTrackedMessages])
const connect = useCallback(async () => {
if (
isConnectingRef.current ||
(wsRef.current &&
(wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING))
) {
return
}
isConnectingRef.current = true
setConnectionState("connecting")
try {
const { token, ws_url } = await getPicoToken()
if (!token) {
console.error("No pico token available")
setConnectionState("error")
isConnectingRef.current = false
return
}
// If the backend returns a localhost URL but we are accessing it via a LAN IP
// (e.g., from a mobile device during dev), rewrite the hostname to match.
let finalWsUrl = ws_url
try {
const parsedUrl = new URL(ws_url)
const isLocalHost =
parsedUrl.hostname === "localhost" ||
parsedUrl.hostname === "127.0.0.1" ||
parsedUrl.hostname === "0.0.0.0"
const isBrowserLocal =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1"
if (isLocalHost && !isBrowserLocal) {
parsedUrl.hostname = window.location.hostname
finalWsUrl = parsedUrl.toString()
}
} catch (e) {
console.warn("Could not parse ws_url:", e)
}
// Build WebSocket URL with session_id
const sessionId = activeSessionIdRef.current
const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(sessionId)}`
const socket = new WebSocket(url)
socket.onopen = () => {
setConnectionState("connected")
isConnectingRef.current = false
}
socket.onmessage = (event) => {
try {
const msg: PicoMessage = JSON.parse(event.data)
handlePicoMessage(msg)
} catch {
console.warn("Non-JSON message from pico:", event.data)
}
}
socket.onclose = () => {
setConnectionState("disconnected")
wsRef.current = null
isConnectingRef.current = false
}
socket.onerror = () => {
setConnectionState("error")
isConnectingRef.current = false
}
wsRef.current = socket
} catch (err) {
console.error("Failed to connect to pico:", err)
setConnectionState("error")
isConnectingRef.current = false
}
}, [handlePicoMessage])
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setConnectionState("disconnected")
isConnectingRef.current = false
}, [])
// Auto connect/disconnect based on gateway state
useEffect(() => {
// Wrap in setTimeout to avoid React calling setState synchronously during render
const timerId = setTimeout(() => {
if (gatewayState === "running") {
connect()
} else {
disconnect()
}
}, 0)
return () => clearTimeout(timerId)
}, [gatewayState, connect, disconnect])
// Cleanup on unmount
useEffect(() => {
return () => disconnect()
}, [disconnect])
const sendMessage = useCallback((content: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
console.warn("WebSocket not connected")
return
}
const id = `msg-${++msgIdCounter.current}-${Date.now()}`
const timestampRaw = Date.now()
// Add user message to local state
setTrackedMessages((prev) => [
...prev,
{ id, role: "user", content, timestamp: timestampRaw },
])
// Show typing indicator immediately
setIsTyping(true)
// Send via Pico Protocol
const picoMsg: PicoMessage = {
type: "message.send",
id,
payload: { content },
}
wsRef.current.send(JSON.stringify(picoMsg))
}, [setTrackedMessages])
// Switch to a historical session
const switchSession = useCallback(
async (sessionId: string) => {
if (sessionId === activeSessionIdRef.current) {
return
}
try {
const historyMessages = await loadSessionMessages(sessionId)
// Only switch the active websocket session after history has loaded successfully.
disconnect()
setActiveSessionId(sessionId)
setIsTyping(false)
setTrackedMessages(historyMessages)
} catch (err) {
console.error("Failed to load session history:", err)
toast.error(t("chat.historyOpenFailed"))
return
}
setTimeout(() => {
if (gatewayState === "running") {
connect()
}
}, 100)
},
[connect, disconnect, gatewayState, loadSessionMessages, setTrackedMessages, t],
)
// Start a new empty chat
const newChat = useCallback(() => {
if (messages.length === 0) {
return
}
disconnect()
const newId = generateSessionId()
setActiveSessionId(newId)
setTrackedMessages([])
setIsTyping(false)
// Reconnect with the fresh session
setTimeout(() => {
if (gatewayState === "running") {
connect()
}
}, 100)
}, [disconnect, connect, gatewayState, messages.length, setTrackedMessages])
return { return {
messages, messages,
connectionState, connectionState,
isTyping, isTyping,
activeSessionId, activeSessionId,
sendMessage, sendMessage: sendChatMessage,
switchSession, switchSession: switchChatSession,
newChat, newChat: newChatSession,
} }
} }

View file

@ -58,11 +58,14 @@
}, },
"action": { "action": {
"start": "Start Gateway", "start": "Start Gateway",
"stop": "Stop Gateway" "stop": "Stop Gateway",
"restart": "Restart Gateway"
}, },
"status": { "status": {
"starting": "Starting Gateway..." "starting": "Starting Gateway...",
} "restarting": "Restarting Gateway..."
},
"restartRequired": "Model changes require a gateway restart to take effect."
} }
}, },
"common": { "common": {

View file

@ -58,11 +58,14 @@
}, },
"action": { "action": {
"start": "启动服务", "start": "启动服务",
"stop": "停止服务" "stop": "停止服务",
"restart": "重启服务"
}, },
"status": { "status": {
"starting": "服务启动中..." "starting": "服务启动中...",
} "restarting": "服务重启中..."
},
"restartRequired": "切换默认模型后需要重启服务才能生效。"
} }
}, },
"common": { "common": {

View file

@ -0,0 +1,405 @@
import { getDefaultStore } from "jotai"
import { toast } from "sonner"
import { getPicoToken } from "@/api/pico"
import { getSessionHistory } from "@/api/sessions"
import i18n from "@/i18n"
import {
clearStoredSessionId,
generateSessionId,
normalizeUnixTimestamp,
readStoredSessionId,
} from "@/lib/pico-chat-state"
import { type ChatMessage, getChatState, updateChatStore } from "@/store/chat"
import { gatewayAtom } from "@/store/gateway"
interface PicoMessage {
type: string
id?: string
session_id?: string
timestamp?: number | string
payload?: Record<string, unknown>
}
const store = getDefaultStore()
let wsRef: WebSocket | null = null
let isConnecting = false
let msgIdCounter = 0
let activeSessionIdRef = getChatState().activeSessionId
let initialized = false
let unsubscribeGateway: (() => void) | null = null
let hydratePromise: Promise<void> | null = null
let connectionGeneration = 0
async function loadSessionMessages(sessionId: string): Promise<ChatMessage[]> {
const detail = await getSessionHistory(sessionId)
const fallbackTime = detail.updated
return detail.messages.map((message, index) => ({
id: `hist-${index}-${Date.now()}`,
role: message.role,
content: message.content,
timestamp: fallbackTime,
}))
}
function handlePicoMessage(message: PicoMessage) {
const payload = message.payload || {}
switch (message.type) {
case "message.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
const timestamp =
message.timestamp !== undefined &&
Number.isFinite(Number(message.timestamp))
? normalizeUnixTimestamp(Number(message.timestamp))
: Date.now()
updateChatStore((prev) => ({
messages: [
...prev.messages,
{
id: messageId,
role: "assistant",
content,
timestamp,
},
],
isTyping: false,
}))
break
}
case "message.update": {
const content = (payload.content as string) || ""
const messageId = payload.message_id as string
if (!messageId) {
break
}
updateChatStore((prev) => ({
messages: prev.messages.map((msg) =>
msg.id === messageId ? { ...msg, content } : msg,
),
}))
break
}
case "typing.start":
updateChatStore({ isTyping: true })
break
case "typing.stop":
updateChatStore({ isTyping: false })
break
case "error":
console.error("Pico error:", payload)
updateChatStore({ isTyping: false })
break
case "pong":
break
default:
console.log("Unknown pico message type:", message.type)
}
}
function setActiveSessionId(sessionId: string) {
activeSessionIdRef = sessionId
updateChatStore({ activeSessionId: sessionId })
}
export async function connectChat() {
if (store.get(gatewayAtom).status !== "running") {
return
}
if (
isConnecting ||
(wsRef &&
(wsRef.readyState === WebSocket.OPEN ||
wsRef.readyState === WebSocket.CONNECTING))
) {
return
}
const generation = connectionGeneration + 1
connectionGeneration = generation
isConnecting = true
updateChatStore({ connectionState: "connecting" })
try {
const { token, ws_url } = await getPicoToken()
if (generation !== connectionGeneration) {
return
}
if (!token) {
console.error("No pico token available")
updateChatStore({ connectionState: "error" })
isConnecting = false
return
}
let finalWsUrl = ws_url
try {
const parsedUrl = new URL(ws_url)
const isLocalHost =
parsedUrl.hostname === "localhost" ||
parsedUrl.hostname === "127.0.0.1" ||
parsedUrl.hostname === "0.0.0.0"
const isBrowserLocal =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1"
if (isLocalHost && !isBrowserLocal) {
parsedUrl.hostname = window.location.hostname
finalWsUrl = parsedUrl.toString()
}
} catch (error) {
console.warn("Could not parse ws_url:", error)
}
const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(activeSessionIdRef)}`
const socket = new WebSocket(url)
if (generation !== connectionGeneration) {
socket.close()
return
}
socket.onopen = () => {
if (wsRef !== socket) {
return
}
updateChatStore({ connectionState: "connected" })
isConnecting = false
}
socket.onmessage = (event) => {
try {
const message: PicoMessage = JSON.parse(event.data)
handlePicoMessage(message)
} catch {
console.warn("Non-JSON message from pico:", event.data)
}
}
socket.onclose = () => {
if (wsRef !== socket) {
return
}
wsRef = null
isConnecting = false
updateChatStore({
connectionState: "disconnected",
isTyping: false,
})
}
socket.onerror = () => {
if (wsRef !== socket) {
return
}
isConnecting = false
updateChatStore({ connectionState: "error" })
}
wsRef = socket
} catch (error) {
if (generation !== connectionGeneration) {
return
}
console.error("Failed to connect to pico:", error)
updateChatStore({ connectionState: "error" })
isConnecting = false
}
}
export function disconnectChat() {
connectionGeneration += 1
const socket = wsRef
wsRef = null
isConnecting = false
if (socket) {
socket.close()
}
updateChatStore({
connectionState: "disconnected",
isTyping: false,
})
}
export async function hydrateActiveSession() {
if (hydratePromise) {
return hydratePromise
}
const state = getChatState()
const storedSessionId = readStoredSessionId()
if (
!storedSessionId ||
state.hasHydratedActiveSession ||
state.messages.length > 0 ||
storedSessionId !== state.activeSessionId
) {
if (!state.hasHydratedActiveSession) {
updateChatStore({ hasHydratedActiveSession: true })
}
return
}
hydratePromise = loadSessionMessages(storedSessionId)
.then((historyMessages) => {
const currentState = getChatState()
if (currentState.activeSessionId !== storedSessionId) {
return
}
if (currentState.messages.length > 0) {
updateChatStore({ hasHydratedActiveSession: true })
return
}
updateChatStore({
messages: historyMessages,
isTyping: false,
hasHydratedActiveSession: true,
})
})
.catch((error) => {
console.error("Failed to restore last session history:", error)
const currentState = getChatState()
if (currentState.activeSessionId !== storedSessionId) {
return
}
if (currentState.messages.length > 0) {
updateChatStore({ hasHydratedActiveSession: true })
return
}
clearStoredSessionId()
updateChatStore({
messages: [],
isTyping: false,
hasHydratedActiveSession: true,
})
})
.finally(() => {
hydratePromise = null
})
return hydratePromise
}
export function sendChatMessage(content: string) {
if (!wsRef || wsRef.readyState !== WebSocket.OPEN) {
console.warn("WebSocket not connected")
return
}
const id = `msg-${++msgIdCounter}-${Date.now()}`
updateChatStore((prev) => ({
messages: [
...prev.messages,
{ id, role: "user", content, timestamp: Date.now() },
],
isTyping: true,
}))
wsRef.send(
JSON.stringify({
type: "message.send",
id,
payload: { content },
}),
)
}
export async function switchChatSession(sessionId: string) {
if (sessionId === activeSessionIdRef) {
return
}
try {
const historyMessages = await loadSessionMessages(sessionId)
disconnectChat()
setActiveSessionId(sessionId)
updateChatStore({
messages: historyMessages,
isTyping: false,
hasHydratedActiveSession: true,
})
if (store.get(gatewayAtom).status === "running") {
await connectChat()
}
} catch (error) {
console.error("Failed to load session history:", error)
toast.error(i18n.t("chat.historyOpenFailed"))
}
}
export async function newChatSession() {
if (getChatState().messages.length === 0) {
return
}
disconnectChat()
setActiveSessionId(generateSessionId())
updateChatStore({
messages: [],
isTyping: false,
hasHydratedActiveSession: true,
})
if (store.get(gatewayAtom).status === "running") {
await connectChat()
}
}
export function initializeChatStore() {
if (initialized) {
return
}
initialized = true
activeSessionIdRef = getChatState().activeSessionId
const syncConnectionWithGateway = () => {
if (store.get(gatewayAtom).status === "running") {
void connectChat()
return
}
disconnectChat()
}
unsubscribeGateway = store.sub(gatewayAtom, syncConnectionWithGateway)
if (!readStoredSessionId()) {
updateChatStore({ hasHydratedActiveSession: true })
}
syncConnectionWithGateway()
}
export function teardownChatStore() {
unsubscribeGateway?.()
unsubscribeGateway = null
initialized = false
disconnectChat()
}

View file

@ -0,0 +1,59 @@
const LAST_SESSION_STORAGE_KEY = "picoclaw:last-session-id"
const UNIX_MS_THRESHOLD = 1e12
function readStorageValue() {
return (
globalThis.localStorage?.getItem(LAST_SESSION_STORAGE_KEY)?.trim() || ""
)
}
export function readStoredSessionId(): string {
return readStorageValue()
}
export function writeStoredSessionId(sessionId: string) {
if (sessionId) {
globalThis.localStorage?.setItem(LAST_SESSION_STORAGE_KEY, sessionId)
return
}
globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)
}
export function clearStoredSessionId() {
globalThis.localStorage?.removeItem(LAST_SESSION_STORAGE_KEY)
}
export function generateSessionId(): string {
const webCrypto = globalThis.crypto
if (webCrypto && typeof webCrypto.randomUUID === "function") {
return webCrypto.randomUUID()
}
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
const bytes = new Uint8Array(16)
webCrypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"))
return (
`${hex[0]}${hex[1]}${hex[2]}${hex[3]}-` +
`${hex[4]}${hex[5]}-` +
`${hex[6]}${hex[7]}-` +
`${hex[8]}${hex[9]}-` +
`${hex[10]}${hex[11]}${hex[12]}${hex[13]}${hex[14]}${hex[15]}`
)
}
return `session-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
}
export function getInitialActiveSessionId(): string {
return readStorageValue() || generateSessionId()
}
export function normalizeUnixTimestamp(timestamp: number): number {
return timestamp < UNIX_MS_THRESHOLD ? timestamp * 1000 : timestamp
}

View file

@ -1,9 +1,15 @@
import { Outlet, createRootRoute } from "@tanstack/react-router" import { Outlet, createRootRoute } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"
import { AppLayout } from "@/components/app-layout" import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/lib/pico-chat-controller"
const RootLayout = () => { const RootLayout = () => {
useEffect(() => {
initializeChatStore()
}, [])
return ( return (
<AppLayout> <AppLayout>
<Outlet /> <Outlet />

View file

@ -0,0 +1,62 @@
import { atom, getDefaultStore } from "jotai"
import {
getInitialActiveSessionId,
writeStoredSessionId,
} from "@/lib/pico-chat-state"
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
timestamp: number | string
}
export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "error"
export interface ChatStoreState {
messages: ChatMessage[]
connectionState: ConnectionState
isTyping: boolean
activeSessionId: string
hasHydratedActiveSession: boolean
}
type ChatStorePatch = Partial<ChatStoreState>
const DEFAULT_CHAT_STATE: ChatStoreState = {
messages: [],
connectionState: "disconnected",
isTyping: false,
activeSessionId: getInitialActiveSessionId(),
hasHydratedActiveSession: false,
}
export const chatAtom = atom<ChatStoreState>(DEFAULT_CHAT_STATE)
const store = getDefaultStore()
export function getChatState() {
return store.get(chatAtom)
}
export function updateChatStore(
patch:
| ChatStorePatch
| ((prev: ChatStoreState) => ChatStorePatch | ChatStoreState),
) {
store.set(chatAtom, (prev) => {
const nextPatch = typeof patch === "function" ? patch(prev) : patch
const next = { ...prev, ...nextPatch }
if (next.activeSessionId !== prev.activeSessionId) {
writeStoredSessionId(next.activeSessionId)
}
return next
})
}

View file

@ -5,6 +5,7 @@ import { type GatewayStatusResponse, getGatewayStatus } from "@/api/gateway"
export type GatewayState = export type GatewayState =
| "running" | "running"
| "starting" | "starting"
| "restarting"
| "stopped" | "stopped"
| "error" | "error"
| "unknown" | "unknown"
@ -12,19 +13,54 @@ export type GatewayState =
export interface GatewayStoreState { export interface GatewayStoreState {
status: GatewayState status: GatewayState
canStart: boolean canStart: boolean
restartRequired: boolean
}
type GatewayStorePatch = Partial<GatewayStoreState>
const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
status: "unknown",
canStart: true,
restartRequired: false,
} }
// Global atom for gateway state // Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>({ export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
status: "unknown",
canStart: true,
})
function applyGatewayStatusToStore(data: GatewayStatusResponse) { function normalizeGatewayStoreState(
getDefaultStore().set(gatewayAtom, (prev) => ({ prev: GatewayStoreState,
...prev, patch: GatewayStorePatch,
status: data.gateway_status ?? "unknown", ) {
canStart: data.gateway_start_allowed ?? true, return { ...prev, ...patch }
}
export function updateGatewayStore(
patch:
| GatewayStorePatch
| ((prev: GatewayStoreState) => GatewayStorePatch | GatewayStoreState),
) {
getDefaultStore().set(gatewayAtom, (prev) => {
const nextPatch = typeof patch === "function" ? patch(prev) : patch
return normalizeGatewayStoreState(prev, nextPatch)
})
}
export function applyGatewayStatusToStore(
data: Partial<
Pick<
GatewayStatusResponse,
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
>
>,
) {
updateGatewayStore((prev) => ({
status: data.gateway_status ?? prev.status,
canStart: data.gateway_start_allowed ?? prev.canStart,
restartRequired:
data.gateway_restart_required ??
(data.gateway_status && data.gateway_status !== "running"
? false
: prev.restartRequired),
})) }))
} }
@ -33,6 +69,6 @@ export async function refreshGatewayState() {
const status = await getGatewayStatus() const status = await getGatewayStatus()
applyGatewayStatusToStore(status) applyGatewayStatusToStore(status)
} catch { } catch {
// Best-effort refresh only; keep current state on error. updateGatewayStore(DEFAULT_GATEWAY_STATE)
} }
} }

View file

@ -1 +1,2 @@
export * from "./gateway" export * from "./gateway"
export * from "./chat"

View file

@ -1,49 +1,59 @@
--- ---
name: weather name: weather
description: Get current weather and forecasts (no API key required). description: Get current weather and forecasts with verified location matching (no API key required).
homepage: https://wttr.in/:help homepage: https://wttr.in/:help
metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}} metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
--- ---
# Weather # Weather
Two free services, no API keys needed. Use the most reliable location match first. For Chinese city names or other non-Latin input, prefer `wttr.in` with the original query because it resolves native names directly. Use Open-Meteo for structured current conditions and forecasts only after you have confirmed the exact city.
## wttr.in (primary) ## Accuracy Rules
Quick one-liner: - Always restate the matched location, region/country, and observation time in the final answer.
- Do not trust the first geocoding hit blindly. Check `country`, `admin1`, `admin2`, and `population`.
- For Chinese city queries, do not send Hanzi directly to Open-Meteo geocoding unless the top result is obviously correct. Prefer `wttr.in` with the original Chinese name, or geocode the English/pinyin city name instead.
- If multiple plausible matches remain, ask a follow-up question or state the assumption clearly.
- Use `timezone=auto` when calling Open-Meteo so the reported time matches the location.
## wttr.in (best for direct city-name queries)
Quick current conditions:
```bash ```bash
curl -s "wttr.in/London?format=3" curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
# Output: London: ⛅️ +8°C
``` ```
Compact format: Chinese city example:
```bash ```bash
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w" curl -s "https://wttr.in/%E6%88%90%E9%83%BD?format=%l:+%c+%t+%h+%w"
# Output: London: ⛅️ +8°C 71% ↙5km/h curl -s "https://wttr.in/%E4%B8%8A%E6%B5%B7?format=%l:+%c+%t+%h+%w"
``` ```
Full forecast: JSON output if you need more detail:
```bash ```bash
curl -s "wttr.in/London?T" curl -s "https://wttr.in/Chengdu?format=j1"
``` ```
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
Tips: Tips:
- URL-encode spaces: `wttr.in/New+York` - URL-encode spaces: `New York` -> `New+York`
- Airport codes: `wttr.in/JFK` - URL-encode non-ASCII text before sending the request
- Units: `?m` (metric) `?u` (USCS) - Use `?m` for metric units and `?u` for US units
- Today only: `?1` · Current only: `?0`
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
## Open-Meteo (fallback, JSON) ## Open-Meteo (best for structured forecasts)
Free, no key, good for programmatic use: 1. Geocode the city and verify the returned location metadata:
```bash ```bash
curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12&current_weather=true" curl -s "https://geocoding-api.open-meteo.com/v1/search?name=Chengdu&count=3&language=en&format=json"
``` ```
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode. 2. Query current weather and today's forecast with the verified coordinates:
```bash
curl -s "https://api.open-meteo.com/v1/forecast?latitude=30.66667&longitude=104.06667&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=1&timezone=auto"
```
Important:
- For Chinese inputs like `成都`, geocoding `name=%E6%88%90%E9%83%BD` may return smaller homonym locations first. Prefer `Chengdu` after verifying it matches Sichuan, China.
- If geocoding looks suspicious, fall back to `wttr.in` for the original city name instead of presenting a likely wrong result.
Docs: https://open-meteo.com/en/docs Docs: https://open-meteo.com/en/docs