Merge branch 'main' of https://github.com/sipeed/picoclaw into feat-azure-openai-support
This commit is contained in:
commit
1264e99aa8
17 changed files with 841 additions and 497 deletions
|
|
@ -9,7 +9,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/ergochat/readline"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"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/bwmarrin/discordgo v0.29.0
|
||||
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/readline v0.1.3
|
||||
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/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.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/time v0.14.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mautrix v0.26.3
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
|
@ -60,7 +61,6 @@ require (
|
|||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/term v0.40.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/mathutil v1.7.1 // 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/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/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/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||
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.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
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-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-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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
|||
|
|
@ -373,9 +373,37 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
|||
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)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -522,3 +522,101 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
|
|||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
|
||||
mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs)
|
||||
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
|
||||
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
||||
mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop)
|
||||
|
|
@ -560,16 +561,16 @@ 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
|
||||
func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data := h.gatewayStatusData(r, true)
|
||||
data := h.gatewayStatusData()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[string]any {
|
||||
func (h *Handler) gatewayStatusData() map[string]any {
|
||||
data := map[string]any{}
|
||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||
configDefaultModel := ""
|
||||
|
|
@ -661,16 +662,22 @@ func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) map[strin
|
|||
}
|
||||
}
|
||||
|
||||
if includeLogs {
|
||||
appendGatewayLogs(r, data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// appendGatewayLogs reads log_offset and log_run_id query params from the request
|
||||
// and populates the response data map with incremental log lines.
|
||||
func appendGatewayLogs(r *http.Request, data map[string]any) {
|
||||
// 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")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// gatewayLogsData reads log_offset and log_run_id query params from the request
|
||||
// and returns incremental log lines.
|
||||
func gatewayLogsData(r *http.Request) map[string]any {
|
||||
data := map[string]any{}
|
||||
clientOffset := 0
|
||||
clientRunID := -1
|
||||
|
||||
|
|
@ -692,7 +699,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
|
|||
data["logs"] = []string{}
|
||||
data["log_total"] = 0
|
||||
data["log_run_id"] = 0
|
||||
return
|
||||
return data
|
||||
}
|
||||
|
||||
// If runID changed, reset offset to get all logs from new run
|
||||
|
|
@ -709,6 +716,7 @@ func appendGatewayLogs(r *http.Request, data map[string]any) {
|
|||
data["logs"] = lines
|
||||
data["log_total"] = total
|
||||
data["log_run_id"] = runID
|
||||
return data
|
||||
}
|
||||
|
||||
// handleGatewayEvents serves an SSE stream of gateway state change events.
|
||||
|
|
@ -751,7 +759,7 @@ func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// currentGatewayStatus returns the current gateway status as a JSON string.
|
||||
func (h *Handler) currentGatewayStatus() string {
|
||||
data := h.gatewayStatusData(nil, false)
|
||||
data := h.gatewayStatusData()
|
||||
encoded, _ := json.Marshal(data)
|
||||
return string(encoded)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -707,6 +707,79 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
|
@ -743,33 +816,36 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
|||
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
|
||||
}
|
||||
|
||||
statusRec := httptest.NewRecorder()
|
||||
statusReq := httptest.NewRequest(
|
||||
logsRec := httptest.NewRecorder()
|
||||
logsReq := httptest.NewRequest(
|
||||
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,
|
||||
)
|
||||
mux.ServeHTTP(statusRec, statusReq)
|
||||
mux.ServeHTTP(logsRec, logsReq)
|
||||
|
||||
if statusRec.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK)
|
||||
if logsRec.Code != http.StatusOK {
|
||||
t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var statusBody map[string]any
|
||||
if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil {
|
||||
t.Fatalf("unmarshal status response: %v", err)
|
||||
var logsBody map[string]any
|
||||
if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
|
||||
t.Fatalf("unmarshal logs response: %v", err)
|
||||
}
|
||||
|
||||
logs, ok := statusBody["logs"].([]any)
|
||||
logs, ok := logsBody["logs"].([]any)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ interface GatewayStatusResponse {
|
|||
pid?: number
|
||||
boot_default_model?: string
|
||||
config_default_model?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GatewayLogsResponse {
|
||||
logs?: string[]
|
||||
log_total?: number
|
||||
log_run_id?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GatewayActionResponse {
|
||||
|
|
@ -31,10 +34,14 @@ async function request<T>(path: string, options?: RequestInit): 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_run_id?: number
|
||||
}): Promise<GatewayStatusResponse> {
|
||||
}): Promise<GatewayLogsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.log_offset !== undefined) {
|
||||
params.set("log_offset", options.log_offset.toString())
|
||||
|
|
@ -43,7 +50,7 @@ export async function getGatewayStatus(options?: {
|
|||
params.set("log_run_id", options.log_run_id.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> {
|
||||
|
|
@ -70,4 +77,8 @@ export async function clearGatewayLogs(): Promise<GatewayActionResponse> {
|
|||
})
|
||||
}
|
||||
|
||||
export type { GatewayStatusResponse, GatewayActionResponse }
|
||||
export type {
|
||||
GatewayStatusResponse,
|
||||
GatewayLogsResponse,
|
||||
GatewayActionResponse,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useChatModels } from "@/hooks/use-chat-models"
|
|||
import { useGateway } from "@/hooks/use-gateway"
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||
import { hydrateActiveSession } from "@/lib/pico-chat-controller"
|
||||
|
||||
export function ChatPage() {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -67,6 +68,10 @@ export function ChatPage() {
|
|||
syncScrollState(e.currentTarget)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void hydrateActiveSession()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
if (isAtBottom) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useAtomValue } from "jotai"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { clearGatewayLogs, getGatewayStatus } from "@/api/gateway"
|
||||
import { clearGatewayLogs, getGatewayLogs } from "@/api/gateway"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
export function useGatewayLogs() {
|
||||
|
|
@ -49,7 +49,7 @@ export function useGatewayLogs() {
|
|||
const requestToken = syncTokenRef.current
|
||||
const requestOffset = logOffsetRef.current
|
||||
const requestRunId = logRunIdRef.current
|
||||
const data = await getGatewayStatus({
|
||||
const data = await getGatewayLogs({
|
||||
log_offset: requestOffset,
|
||||
log_run_id: requestRunId,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,79 +1,12 @@
|
|||
import dayjs from "dayjs"
|
||||
import { useAtomValue } from "jotai"
|
||||
|
||||
import {
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getPicoToken } from "@/api/pico"
|
||||
import { getSessionHistory } from "@/api/sessions"
|
||||
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)}`
|
||||
}
|
||||
newChatSession,
|
||||
sendChatMessage,
|
||||
switchChatSession,
|
||||
} from "@/lib/pico-chat-controller"
|
||||
import { chatAtom } from "@/store/chat"
|
||||
|
||||
const UNIX_MS_THRESHOLD = 1e12
|
||||
|
||||
|
|
@ -124,369 +57,16 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
|||
}
|
||||
|
||||
export function usePicoChat() {
|
||||
const { t } = useTranslation()
|
||||
const { status: gatewayState } = useAtomValue(gatewayAtom)
|
||||
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])
|
||||
const { messages, connectionState, isTyping, activeSessionId } =
|
||||
useAtomValue(chatAtom)
|
||||
|
||||
return {
|
||||
messages,
|
||||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
sendMessage: sendChatMessage,
|
||||
switchSession: switchChatSession,
|
||||
newChat: newChatSession,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { AppLayout } from "@/components/app-layout"
|
||||
import { initializeChatStore } from "@/lib/pico-chat-controller"
|
||||
|
||||
const RootLayout = () => {
|
||||
useEffect(() => {
|
||||
initializeChatStore()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<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
|
||||
})
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./gateway"
|
||||
export * from "./chat"
|
||||
|
|
|
|||
|
|
@ -1,49 +1,59 @@
|
|||
---
|
||||
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
|
||||
metadata: {"nanobot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
|
||||
---
|
||||
|
||||
# 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
|
||||
curl -s "wttr.in/London?format=3"
|
||||
# Output: London: ⛅️ +8°C
|
||||
curl -s "https://wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
```
|
||||
|
||||
Compact format:
|
||||
Chinese city example:
|
||||
```bash
|
||||
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
|
||||
# Output: London: ⛅️ +8°C 71% ↙5km/h
|
||||
curl -s "https://wttr.in/%E6%88%90%E9%83%BD?format=%l:+%c+%t+%h+%w"
|
||||
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
|
||||
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:
|
||||
- URL-encode spaces: `wttr.in/New+York`
|
||||
- Airport codes: `wttr.in/JFK`
|
||||
- Units: `?m` (metric) `?u` (USCS)
|
||||
- Today only: `?1` · Current only: `?0`
|
||||
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
|
||||
- URL-encode spaces: `New York` -> `New+York`
|
||||
- URL-encode non-ASCII text before sending the request
|
||||
- Use `?m` for metric units and `?u` for US units
|
||||
|
||||
## 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
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue