Merge branch 'sipeed:main' into fix/cron-job-index
This commit is contained in:
commit
3a9bf9db81
38 changed files with 1881 additions and 654 deletions
|
|
@ -27,6 +27,7 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- netbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
@ -44,6 +45,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
|
- id: picoclaw-launcher
|
||||||
binary: picoclaw-launcher
|
binary: picoclaw-launcher
|
||||||
|
|
@ -58,6 +65,7 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- netbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
@ -75,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
|
||||||
|
|
@ -89,6 +103,7 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- netbsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|
@ -106,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
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -181,6 +181,8 @@ build-all: generate
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## install: Install picoclaw to system and copy builtin skills
|
||||||
|
|
|
||||||
|
|
@ -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
6
go.mod
|
|
@ -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
9
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,9 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
// Reset msg_seq counter for new inbound message.
|
// Reset msg_seq counter for new inbound message.
|
||||||
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{
|
||||||
|
"account_id": senderID,
|
||||||
|
}
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
Platform: "qq",
|
Platform: "qq",
|
||||||
|
|
@ -495,7 +497,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"group_id": data.GroupID,
|
"account_id": senderID,
|
||||||
|
"group_id": data.GroupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
|
|
|
||||||
44
pkg/channels/qq/qq_test.go
Normal file
44
pkg/channels/qq/qq_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package qq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tencent-connect/botgo/dto"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &QQChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
|
||||||
|
dedup: make(map[string]time.Time),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{
|
||||||
|
ID: "msg-1",
|
||||||
|
Content: "hello",
|
||||||
|
Author: &dto.User{
|
||||||
|
ID: "7750283E123456",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleC2CMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message")
|
||||||
|
}
|
||||||
|
if inbound.Metadata["account_id"] != "7750283E123456" {
|
||||||
|
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep track of explicit username format
|
||||||
|
isAtUsername := strings.HasPrefix(allowed, "@")
|
||||||
|
|
||||||
// Strip leading "@" for username matching
|
// Strip leading "@" for username matching
|
||||||
trimmed := strings.TrimPrefix(allowed, "@")
|
trimmed := strings.TrimPrefix(allowed, "@")
|
||||||
|
|
||||||
|
|
@ -75,11 +78,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match against Username
|
// Match against Username only when explicitly requested via "@username"
|
||||||
if sender.Username != "" {
|
if isAtUsername && sender.Username != "" && sender.Username == trimmed {
|
||||||
if sender.Username == trimmed || sender.Username == allowedUser {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match compound sender format against allowed parts
|
// Match compound sender format against allowed parts
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,16 @@ func TestMatchAllowed(t *testing.T) {
|
||||||
allowed: "@alice",
|
allowed: "@alice",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "plain entry does not match username",
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "discord",
|
||||||
|
PlatformID: "999999",
|
||||||
|
Username: "123456",
|
||||||
|
},
|
||||||
|
allowed: "123456",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "@username does not match",
|
name: "@username does not match",
|
||||||
sender: telegramSender,
|
sender: telegramSender,
|
||||||
|
|
@ -123,6 +133,16 @@ func TestMatchAllowed(t *testing.T) {
|
||||||
allowed: "999|alice",
|
allowed: "999|alice",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "compound matches by ID when username differs",
|
||||||
|
sender: bus.SenderInfo{
|
||||||
|
Platform: "discord",
|
||||||
|
PlatformID: "123456",
|
||||||
|
Username: "not123456",
|
||||||
|
},
|
||||||
|
allowed: "123456|alice",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "compound does not match",
|
name: "compound does not match",
|
||||||
sender: telegramSender,
|
sender: telegramSender,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,14 @@
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"start": "启动服务",
|
"start": "启动服务",
|
||||||
"stop": "停止服务"
|
"stop": "停止服务",
|
||||||
|
"restart": "重启服务"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"starting": "服务启动中..."
|
"starting": "服务启动中...",
|
||||||
}
|
"restarting": "服务重启中..."
|
||||||
|
},
|
||||||
|
"restartRequired": "切换默认模型后需要重启服务才能生效。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|
|
||||||
405
web/frontend/src/lib/pico-chat-controller.ts
Normal file
405
web/frontend/src/lib/pico-chat-controller.ts
Normal 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()
|
||||||
|
}
|
||||||
59
web/frontend/src/lib/pico-chat-state.ts
Normal file
59
web/frontend/src/lib/pico-chat-state.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
62
web/frontend/src/store/chat.ts
Normal file
62
web/frontend/src/store/chat.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from "./gateway"
|
export * from "./gateway"
|
||||||
|
export * from "./chat"
|
||||||
|
|
|
||||||
|
|
@ -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¤t_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¤t=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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue