From 047a9bb835a9227a808e5a4637e9235e7aab790b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?= Date: Fri, 13 Mar 2026 01:41:27 +0800 Subject: [PATCH 01/11] fix(skill): tighten weather location matching guidance --- workspace/skills/weather/SKILL.md | 54 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/workspace/skills/weather/SKILL.md b/workspace/skills/weather/SKILL.md index 8073de192..aa90a9b20 100644 --- a/workspace/skills/weather/SKILL.md +++ b/workspace/skills/weather/SKILL.md @@ -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 From 516f7103b0aa1c5133d4822eea9b08e5313a7c18 Mon Sep 17 00:00:00 2001 From: iMil Date: Fri, 13 Mar 2026 08:19:37 +0100 Subject: [PATCH 02/11] add NetBSD to the list of released platforms (#434) * add NetBSD to the list of released platforms * ignore platforms s390x mips64 and arm for NetBSD * add NetBSD to the build-all target --- .goreleaser.yaml | 7 +++++++ Makefile | 2 ++ 2 files changed, 9 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 622cf054b..8d6d046cc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -27,6 +27,7 @@ builds: - windows - darwin - freebsd + - netbsd goarch: - amd64 - arm64 @@ -44,6 +45,12 @@ builds: ignore: - goos: windows goarch: arm + - goos: netbsd + goarch: s390x + - goos: netbsd + goarch: mips64 + - goos: netbsd + goarch: arm - id: picoclaw-launcher binary: picoclaw-launcher diff --git a/Makefile b/Makefile index 98642703f..2f673d3b9 100644 --- a/Makefile +++ b/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=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=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" ## install: Install picoclaw to system and copy builtin skills From 4ccea5eb93f896c94c0bcf18bd59d69ec86c949a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 13 Mar 2026 15:41:18 +0800 Subject: [PATCH 03/11] fix(identity): prevent allowlist ID entries from matching usernames (#1406) --- pkg/identity/identity.go | 11 ++++++----- pkg/identity/identity_test.go | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 6bc09c210..372bbe38b 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -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 trimmed := strings.TrimPrefix(allowed, "@") @@ -75,11 +78,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool { return true } - // Match against Username - if sender.Username != "" { - if sender.Username == trimmed || sender.Username == allowedUser { - return true - } + // Match against Username only when explicitly requested via "@username" + if isAtUsername && sender.Username != "" && sender.Username == trimmed { + return true } // Match compound sender format against allowed parts diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go index 3d24bd794..a588f1484 100644 --- a/pkg/identity/identity_test.go +++ b/pkg/identity/identity_test.go @@ -104,6 +104,16 @@ func TestMatchAllowed(t *testing.T) { allowed: "@alice", 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", sender: telegramSender, @@ -123,6 +133,16 @@ func TestMatchAllowed(t *testing.T) { allowed: "999|alice", 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", sender: telegramSender, From 87257819f62112fcc59a1766425fd1b09d016b8e Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 13 Mar 2026 16:30:59 +0800 Subject: [PATCH 04/11] feat(web): add restart-required state for default model changes (#1499) - track boot and config default models in gateway status/events - preserve running, starting, and restarting states during health checks - add safer gateway restart handling with stronger backend test coverage - expose restart-required UI and refresh model state after default model update --- web/backend/api/events.go | 7 +- web/backend/api/gateway.go | 342 ++++++++++++--- web/backend/api/gateway_test.go | 390 ++++++++++++++++++ web/frontend/src/api/gateway.ts | 5 +- web/frontend/src/api/models.ts | 2 +- web/frontend/src/components/app-header.tsx | 108 +++-- .../src/components/chat/chat-page.tsx | 22 +- .../src/components/chat/model-selector.tsx | 2 +- .../components/chat/session-history-menu.tsx | 2 +- .../components/models/edit-model-sheet.tsx | 2 +- .../src/components/models/models-page.tsx | 2 + web/frontend/src/components/page-header.tsx | 16 +- web/frontend/src/hooks/use-chat-models.ts | 36 +- web/frontend/src/hooks/use-gateway-logs.ts | 2 +- web/frontend/src/hooks/use-gateway.ts | 83 ++-- web/frontend/src/hooks/use-pico-chat.ts | 180 ++++---- web/frontend/src/i18n/locales/en.json | 9 +- web/frontend/src/i18n/locales/zh.json | 9 +- web/frontend/src/store/gateway.ts | 56 ++- 19 files changed, 1022 insertions(+), 253 deletions(-) diff --git a/web/backend/api/events.go b/web/backend/api/events.go index 0a8d4a9bb..af44d1824 100644 --- a/web/backend/api/events.go +++ b/web/backend/api/events.go @@ -7,8 +7,11 @@ import ( // GatewayEvent represents a state change event for the gateway process. type GatewayEvent struct { - Status string `json:"gateway_status"` // "running", "starting", "stopped", "error" - PID int `json:"pid,omitempty"` + Status string `json:"gateway_status"` // "running", "starting", "restarting", "stopped", "error" + 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. diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 41f702e32..95b482ce0 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -23,13 +23,29 @@ import ( // gateway holds the state for the managed gateway process. var gateway = struct { - mu sync.Mutex - cmd *exec.Cmd - logs *LogBuffer - events *EventBroadcaster + mu sync.Mutex + cmd *exec.Cmd + bootDefaultModel string + runtimeStatus string + startupDeadline time.Time + logs *LogBuffer + events *EventBroadcaster }{ - logs: NewLogBuffer(200), - events: NewEventBroadcaster(), + runtimeStatus: "stopped", + 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. @@ -65,7 +81,7 @@ func (h *Handler) TryAutoStartGateway() { return } - pid, err := h.startGatewayLocked() + pid, err := h.startGatewayLocked("starting") if err != nil { log.Printf("Failed to auto-start gateway: %v", err) return @@ -131,7 +147,110 @@ func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { 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 execPath := utils.FindPicoclawBinary() @@ -171,11 +290,19 @@ func (h *Handler) startGatewayLocked() (int, error) { } gateway.cmd = cmd + gateway.bootDefaultModel = defaultModelName + setGatewayRuntimeStatusLocked(initialStatus) pid := cmd.Process.Pid log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath) - // Broadcast starting event - gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid}) + // Broadcast the launch state immediately so clients can reflect it without polling. + gateway.events.Broadcast(GatewayEvent{ + Status: initialStatus, + PID: pid, + BootDefaultModel: defaultModelName, + ConfigDefaultModel: defaultModelName, + RestartRequired: false, + }) // Capture stdout/stderr in background go scanPipe(stdoutPipe, gateway.logs) @@ -190,13 +317,23 @@ func (h *Handler) startGatewayLocked() (int, error) { } gateway.mu.Lock() + shouldBroadcastStopped := false if gateway.cmd == cmd { gateway.cmd = nil + gateway.bootDefaultModel = "" + if gateway.runtimeStatus != "restarting" { + setGatewayRuntimeStatusLocked("stopped") + shouldBroadcastStopped = true + } } gateway.mu.Unlock() - // Broadcast stopped event - gateway.events.Broadcast(GatewayEvent{Status: "stopped"}) + if shouldBroadcastStopped { + gateway.events.Broadcast(GatewayEvent{ + Status: "stopped", + RestartRequired: false, + }) + } }() // Start a goroutine to probe health and broadcast "running" once ready @@ -219,12 +356,22 @@ func (h *Handler) startGatewayLocked() (int, error) { healthPort = 18790 } healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort))) - client := http.Client{Timeout: 1 * time.Second} - resp, err := client.Get(healthURL) + resp, err := gatewayHealthGet(healthURL, 1*time.Second) if err == nil { resp.Body.Close() 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 } } @@ -253,6 +400,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { } if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil + setGatewayRuntimeStatusLocked("stopped") } ready, reason, err := h.gatewayStartReady() @@ -274,7 +422,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { return } - pid, err := h.startGatewayLocked() + pid, err := h.startGatewayLocked("starting") if err != nil { http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) return @@ -330,30 +478,72 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { // // POST /api/gateway/restart func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { - gateway.mu.Lock() - - // Stop existing process if running - if gateway.cmd != nil && gateway.cmd.Process != nil { - if isCmdProcessAliveLocked(gateway.cmd) { - // Process is alive, send SIGTERM - if runtime.GOOS == "windows" { - gateway.cmd.Process.Kill() - } else { - gateway.cmd.Process.Signal(syscall.SIGTERM) - } - - // Wait briefly for it to exit - gateway.mu.Unlock() - time.Sleep(2 * time.Second) - gateway.mu.Lock() - } - gateway.cmd = nil + ready, reason, err := h.gatewayStartReady() + if err != nil { + http.Error( + w, + fmt.Sprintf("Failed to validate gateway start conditions: %v", err), + http.StatusInternalServerError, + ) + return + } + if !ready { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": reason, + }) + return } + gateway.mu.Lock() + previousCmd := gateway.cmd + setGatewayRuntimeStatusLocked("restarting") + gateway.events.Broadcast(GatewayEvent{ + Status: "restarting", + RestartRequired: false, + }) gateway.mu.Unlock() - // Start fresh via the existing handler - h.handleGatewayStart(w, r) + if err = stopGatewayProcessForRestart(previousCmd); err != nil { + 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. @@ -374,24 +564,44 @@ func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) // // GET /api/gateway/status func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { + data := h.gatewayStatusData(r, true) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +func (h *Handler) gatewayStatusData(r *http.Request, includeLogs bool) 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 gateway.mu.Lock() processAlive := isGatewayProcessAliveLocked() + bootDefaultModel := "" if processAlive { data["pid"] = gateway.cmd.Process.Pid + if gateway.bootDefaultModel != "" { + data["boot_default_model"] = gateway.bootDefaultModel + bootDefaultModel = gateway.bootDefaultModel + } } gateway.mu.Unlock() if !processAlive { - data["gateway_status"] = "stopped" + gateway.mu.Lock() + data["gateway_status"] = currentGatewayStatusLocked(false) + gateway.mu.Unlock() } else { // Process is alive — probe its health endpoint - cfg, err := config.LoadConfig(h.configPath) host := "127.0.0.1" port := 18790 - if err == nil && cfg != nil { + if cfgErr == nil && cfg != nil { host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) if cfg.Gateway.Port != 0 { port = cfg.Gateway.Port @@ -399,21 +609,31 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { } url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) - client := http.Client{Timeout: 2 * time.Second} - resp, err := client.Get(url) + resp, err := gatewayHealthGet(url, 2*time.Second) if err != nil { - data["gateway_status"] = "starting" + gateway.mu.Lock() + data["gateway_status"] = currentGatewayStatusLocked(true) + gateway.mu.Unlock() } else { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("error") + gateway.mu.Unlock() data["gateway_status"] = "error" data["status_code"] = resp.StatusCode } else { var healthData map[string]any if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("error") + gateway.mu.Unlock() data["gateway_status"] = "error" } else { + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() for k, v := range healthData { data[k] = v } @@ -423,6 +643,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() if readyErr != nil { data["gateway_start_allowed"] = false @@ -434,11 +661,11 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { } } - // Append incremental log data - appendGatewayLogs(r, data) + if includeLogs { + appendGatewayLogs(r, data) + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) + return data } // appendGatewayLogs reads log_offset and log_run_id query params from the request @@ -524,28 +751,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 { - gateway.mu.Lock() - 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 - } - } - + data := h.gatewayStatusData(nil, false) encoded, _ := json.Marshal(data) return string(encoded) } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index d4265776a..fe3fccdee 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -2,19 +2,76 @@ package api import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" + "time" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" "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) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -317,6 +374,339 @@ 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 TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 020e92e3a..1688a5278 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -1,10 +1,13 @@ // API client for gateway process management. interface GatewayStatusResponse { - gateway_status: "running" | "starting" | "stopped" | "error" + gateway_status: "running" | "starting" | "restarting" | "stopped" | "error" gateway_start_allowed?: boolean gateway_start_reason?: string + gateway_restart_required?: boolean pid?: number + boot_default_model?: string + config_default_model?: string logs?: string[] log_total?: number log_run_id?: number diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 6a4544c65..8e49b48b4 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -84,7 +84,7 @@ export async function setDefaultModel( body: JSON.stringify({ model_name: modelName }), }) - void refreshGatewayState() + await refreshGatewayState() return response } diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index 7a50fe0fb..fe0c84e69 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -6,6 +6,7 @@ import { IconMoon, IconPlayerPlay, IconPower, + IconRefresh, IconSun, } from "@tabler/icons-react" import { Link } from "@tanstack/react-router" @@ -31,6 +32,11 @@ import { } from "@/components/ui/dropdown-menu.tsx" import { Separator } from "@/components/ui/separator.tsx" import { SidebarTrigger } from "@/components/ui/sidebar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { useGateway } from "@/hooks/use-gateway.ts" import { useTheme } from "@/hooks/use-theme.ts" @@ -41,27 +47,35 @@ export function AppHeader() { state: gwState, loading: gwLoading, canStart, + restartRequired, start, + restart, stop, } = useGateway() const isRunning = gwState === "running" const isStarting = gwState === "starting" + const isRestarting = gwState === "restarting" const isStopped = gwState === "stopped" || gwState === "unknown" const showNotConnectedHint = - canStart && (gwState === "stopped" || gwState === "error") + !isRestarting && canStart && (gwState === "stopped" || gwState === "error") const [showStopDialog, setShowStopDialog] = React.useState(false) const handleGatewayToggle = () => { - if (gwLoading || (!isRunning && !canStart)) return + if (gwLoading || isRestarting || (!isRunning && !canStart)) return if (isRunning) { setShowStopDialog(true) } else { - start() + void start() } } + const handleGatewayRestart = () => { + if (gwLoading || isRestarting || !restartRequired || !canStart) return + void restart() + } + const confirmStop = () => { setShowStopDialog(false) stop() @@ -115,35 +129,67 @@ export function AppHeader() {
+ {restartRequired && ( + + + + + + {t("header.gateway.restartRequired")} + + + )} + {/* Gateway Start/Stop */} - + {isRunning ? ( + + + + + {t("header.gateway.action.stop")} + + ) : ( + + )} (null) const [isAtBottom, setIsAtBottom] = useState(true) + const [hasScrolled, setHasScrolled] = useState(false) const [input, setInput] = useState("") const { @@ -56,14 +57,22 @@ export function ChatPage() { onDeletedActiveSession: newChat, }) - const handleScroll = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget + const syncScrollState = (element: HTMLDivElement) => { + const { scrollTop, scrollHeight, clientHeight } = element + setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } + const handleScroll = (e: React.UIEvent) => { + syncScrollState(e.currentTarget) + } + useEffect(() => { - if (isAtBottom && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight + if (scrollRef.current) { + if (isAtBottom) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + syncScrollState(scrollRef.current) } }, [messages, isTyping, isAtBottom]) @@ -77,6 +86,9 @@ export function ChatPage() {
diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 4c77944a9..237991a9f 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -110,7 +110,7 @@ export function EditModelSheet({ : undefined, thinking_level: form.thinkingLevel || undefined, }) - if (setAsDefault) { + if (setAsDefault && !model.is_default) { await setDefaultModel(model.model_name) } onSaved() diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index b8e80e709..6776e5ca8 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -79,6 +79,8 @@ export function ModelsPage() { }, [fetchModels]) const handleSetDefault = async (model: ModelInfo) => { + if (model.is_default) return + setSettingDefaultIndex(model.index) try { await setDefaultModel(model.model_name) diff --git a/web/frontend/src/components/page-header.tsx b/web/frontend/src/components/page-header.tsx index 9d4aa6975..656551f39 100644 --- a/web/frontend/src/components/page-header.tsx +++ b/web/frontend/src/components/page-header.tsx @@ -2,16 +2,28 @@ import { IconMenu2 } from "@tabler/icons-react" import type { ReactNode } from "react" import { SidebarTrigger } from "@/components/ui/sidebar" +import { cn } from "@/lib/utils" interface PageHeaderProps { title: string titleExtra?: ReactNode children?: ReactNode + className?: string } -export function PageHeader({ title, titleExtra, children }: PageHeaderProps) { +export function PageHeader({ + title, + titleExtra, + children, + className, +}: PageHeaderProps) { return ( -
+
diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 8a82ceaf3..9afa882db 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -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" @@ -20,6 +20,7 @@ function isLocalModel(model: ModelInfo): boolean { export function useChatModels({ isConnected }: UseChatModelsOptions) { const [modelList, setModelList] = useState([]) const [defaultModelName, setDefaultModelName] = useState("") + const setDefaultRequestIdRef = useRef(0) const loadModels = useCallback(async () => { try { @@ -41,17 +42,28 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { return () => clearTimeout(timerId) }, [isConnected, loadModels]) - const handleSetDefault = useCallback(async (modelName: string) => { - try { - await setDefaultModel(modelName) - setDefaultModelName(modelName) - setModelList((prev) => - prev.map((m) => ({ ...m, is_default: m.model_name === modelName })), - ) - } catch (err) { - console.error("Failed to set default model:", err) - } - }, []) + const handleSetDefault = useCallback( + async (modelName: string) => { + if (modelName === defaultModelName) return + const requestId = ++setDefaultRequestIdRef.current + + try { + await setDefaultModel(modelName) + const data = await getModels() + 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( () => modelList.some((m) => m.configured), diff --git a/web/frontend/src/hooks/use-gateway-logs.ts b/web/frontend/src/hooks/use-gateway-logs.ts index a39e6e930..593e90b26 100644 --- a/web/frontend/src/hooks/use-gateway-logs.ts +++ b/web/frontend/src/hooks/use-gateway-logs.ts @@ -37,7 +37,7 @@ export function useGatewayLogs() { const fetchLogs = async () => { if ( !mounted || - (gateway.status !== "running" && gateway.status !== "starting") + !["running", "starting", "restarting"].includes(gateway.status) ) { if (mounted) { timeout = setTimeout(fetchLogs, 1000) diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 097dc3598..848f4d59c 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -1,31 +1,30 @@ -import { useAtom } from "jotai" +import { useAtomValue } from "jotai" import { useCallback, useEffect, useState } from "react" import { type GatewayStatusResponse, getGatewayStatus, + restartGateway, startGateway, stopGateway, } from "@/api/gateway" -import { gatewayAtom } from "@/store" +import { + applyGatewayStatusToStore, + gatewayAtom, + updateGatewayStore, +} from "@/store" // Global variable to ensure we only have one SSE connection let sseInitialized = false 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 applyGatewayStatus = useCallback( - (data: GatewayStatusResponse) => { - setGateway((prev) => ({ - ...prev, - status: data.gateway_status ?? "unknown", - canStart: data.gateway_start_allowed ?? true, - })) - }, - [setGateway], - ) + const applyGatewayStatus = useCallback((data: GatewayStatusResponse) => { + applyGatewayStatusToStore(data) + }, []) // Initialize global SSE connection once useEffect(() => { @@ -35,9 +34,10 @@ export function useGateway() { getGatewayStatus() .then((data) => applyGatewayStatus(data)) .catch(() => { - setGateway({ + updateGatewayStore({ status: "unknown", canStart: true, + restartRequired: false, }) }) @@ -59,14 +59,7 @@ export function useGateway() { data.gateway_status || typeof data.gateway_start_allowed === "boolean" ) { - setGateway((prev) => ({ - ...prev, - status: data.gateway_status ?? prev.status, - canStart: - typeof data.gateway_start_allowed === "boolean" - ? data.gateway_start_allowed - : prev.canStart, - })) + applyGatewayStatus(data) } } catch { // ignore @@ -75,7 +68,9 @@ export function useGateway() { es.onerror = () => { // EventSource will auto-reconnect - setGateway((prev) => ({ ...prev, status: "unknown" })) + updateGatewayStore((prev) => + prev.status === "restarting" ? {} : { status: "unknown" }, + ) } return () => { @@ -83,7 +78,7 @@ export function useGateway() { es.close() sseInitialized = false } - }, [applyGatewayStatus, setGateway]) + }, [applyGatewayStatus]) const start = useCallback(async () => { if (!canStart) return @@ -92,19 +87,19 @@ export function useGateway() { try { await startGateway() // SSE will push the real state changes, but set optimistic state - setGateway((prev) => ({ ...prev, status: "starting" })) + updateGatewayStore({ status: "starting" }) } catch (err) { console.error("Failed to start gateway:", err) try { const status = await getGatewayStatus() applyGatewayStatus(status) } catch { - setGateway((prev) => ({ ...prev, status: "unknown" })) + updateGatewayStore({ status: "unknown" }) } } finally { setLoading(false) } - }, [applyGatewayStatus, canStart, setGateway]) + }, [applyGatewayStatus, canStart]) const stop = useCallback(async () => { 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 } } diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 7e3066177..2b7a510af 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -130,8 +130,9 @@ export function usePicoChat() { const [connectionState, setConnectionState] = useState("disconnected") const [isTyping, setIsTyping] = useState(false) - const [activeSessionId, setActiveSessionId] = - useState(() => readStoredSessionId() || generateSessionId()) + const [activeSessionId, setActiveSessionId] = useState( + () => readStoredSessionId() || generateSessionId(), + ) const wsRef = useRef(null) const isConnectingRef = useRef(false) @@ -144,9 +145,7 @@ export function usePicoChat() { setMessages((prev) => { const next = typeof nextState === "function" - ? ( - nextState as (prevState: ChatMessage[]) => ChatMessage[] - )(prev) + ? (nextState as (prevState: ChatMessage[]) => ChatMessage[])(prev) : nextState if (next !== prev) { @@ -220,64 +219,69 @@ export function usePicoChat() { } }, [loadSessionMessages, setTrackedMessages]) - const handlePicoMessage = useCallback((msg: PicoMessage) => { - const payload = msg.payload || {} + 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() + 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 + 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) } - - 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]) + }, + [setTrackedMessages], + ) const connect = useCallback(async () => { if ( @@ -389,32 +393,35 @@ export function usePicoChat() { return () => disconnect() }, [disconnect]) - const sendMessage = useCallback((content: string) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - console.warn("WebSocket not connected") - return - } + 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() + 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 }, - ]) + // Add user message to local state + setTrackedMessages((prev) => [ + ...prev, + { id, role: "user", content, timestamp: timestampRaw }, + ]) - // Show typing indicator immediately - setIsTyping(true) + // 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]) + // 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( @@ -443,7 +450,14 @@ export function usePicoChat() { } }, 100) }, - [connect, disconnect, gatewayState, loadSessionMessages, setTrackedMessages, t], + [ + connect, + disconnect, + gatewayState, + loadSessionMessages, + setTrackedMessages, + t, + ], ) // Start a new empty chat diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 453c5905f..b099dec13 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -58,11 +58,14 @@ }, "action": { "start": "Start Gateway", - "stop": "Stop Gateway" + "stop": "Stop Gateway", + "restart": "Restart Gateway" }, "status": { - "starting": "Starting Gateway..." - } + "starting": "Starting Gateway...", + "restarting": "Restarting Gateway..." + }, + "restartRequired": "Model changes require a gateway restart to take effect." } }, "common": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index b6bdedbfa..78093e5c7 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -58,11 +58,14 @@ }, "action": { "start": "启动服务", - "stop": "停止服务" + "stop": "停止服务", + "restart": "重启服务" }, "status": { - "starting": "服务启动中..." - } + "starting": "服务启动中...", + "restarting": "服务重启中..." + }, + "restartRequired": "切换默认模型后需要重启服务才能生效。" } }, "common": { diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts index 89da9d7fd..b7655839c 100644 --- a/web/frontend/src/store/gateway.ts +++ b/web/frontend/src/store/gateway.ts @@ -5,6 +5,7 @@ import { type GatewayStatusResponse, getGatewayStatus } from "@/api/gateway" export type GatewayState = | "running" | "starting" + | "restarting" | "stopped" | "error" | "unknown" @@ -12,19 +13,54 @@ export type GatewayState = export interface GatewayStoreState { status: GatewayState canStart: boolean + restartRequired: boolean +} + +type GatewayStorePatch = Partial + +const DEFAULT_GATEWAY_STATE: GatewayStoreState = { + status: "unknown", + canStart: true, + restartRequired: false, } // Global atom for gateway state -export const gatewayAtom = atom({ - status: "unknown", - canStart: true, -}) +export const gatewayAtom = atom(DEFAULT_GATEWAY_STATE) -function applyGatewayStatusToStore(data: GatewayStatusResponse) { - getDefaultStore().set(gatewayAtom, (prev) => ({ - ...prev, - status: data.gateway_status ?? "unknown", - canStart: data.gateway_start_allowed ?? true, +function normalizeGatewayStoreState( + prev: GatewayStoreState, + patch: GatewayStorePatch, +) { + 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() applyGatewayStatusToStore(status) } catch { - // Best-effort refresh only; keep current state on error. + updateGatewayStore(DEFAULT_GATEWAY_STATE) } } From 9530883d2cad44b4aa59cf8745a43fb0d24e2e75 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:43:00 +0800 Subject: [PATCH 05/11] Fix/Add warning tips for MCP initialization when no valid servers configured (#1497) * add tips for mcp * fix test issue --- pkg/agent/loop_mcp.go | 16 ++++++++++++++++ pkg/agent/loop_test.go | 13 ++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index 2795db52a..962789a06 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -63,6 +63,22 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { 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() { mcpManager := mcp.NewManager() diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index cab82e176..1e8d92db8 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -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-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) + // Test with MCP enabled but no servers - should not initialize manager cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ @@ -791,6 +796,7 @@ func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) { ToolConfig: config.ToolConfig{ 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) } - if !al.mcp.hasManager() { - t.Fatal("expected MCP manager to be initialized in direct agent mode") + // Manager should not be initialized when no servers are configured + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be nil when no servers are configured") } } From 6b72326be1e586ba1229b1b5128674e8e4687183 Mon Sep 17 00:00:00 2001 From: Hakancan <142545736+hkc5@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:16:05 +0000 Subject: [PATCH 06/11] fix: safety guard incorrectly blocks commands with URLs (#1254) * fix: safety guard incorrectly blocks commands with URLs The absolutePathPattern regex was matching URL path components like //github.com as file system paths, causing commands containing URLs to be incorrectly blocked by the workspace restriction safety guard. For example, 'agent-browser open https://github.com' would be blocked because //github.com was treated as an absolute file path outside the working directory. The fix adds a check to skip any path match that starts with '//', as these are URL path components, not file system paths. Fixes #1203 * fix: handle file:// URIs correctly in safety guard The previous fix skipped all paths starting with '//', which incorrectly also skipped file:// URIs that could escape the workspace sandbox. Changes: - Only skip '//' paths when preceded by web URL schemes (http:, https:, ftp:, etc.) - file:// URIs are now properly checked against workspace boundaries - Added TestShellTool_FileURISandboxing to verify the fix Fixes security issue raised by @alexhoshina in PR #1254 * style: fix gofumpt formatting * fix(safety-guard): use exact match position to prevent URL exemption bypass Using strings.Index(cmd, raw) always returned the first occurrence of the matched substring, allowing a bypass where the same //path appeared both inside a URL and as a standalone shell path (e.g. echo https://etc/passwd && cat //etc/passwd would skip the second match). Switch to FindAllStringIndex so each match is evaluated at its actual position in the command string. Adds TestShellTool_URLBypassPrevented to cover the exploit scenario. --- pkg/tools/shell.go | 32 +++++++++++++- pkg/tools/shell_test.go | 98 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 67e2ad257..9ea05bb12 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -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 diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 90265e5bd..c4553020f 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -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) + } + } +} From c69c48ad464db5a6d17e9d8025726926df4171f1 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 13 Mar 2026 17:58:20 +0800 Subject: [PATCH 07/11] refactor(web): split gateway logs out of the status endpoint (#1504) - add a dedicated /api/gateway/logs endpoint for incremental log polling - keep /api/gateway/status focused on runtime and health data only - update frontend log fetching to use the new API and add backend tests covering the status/logs separation and cleared-log behavior --- web/backend/api/gateway.go | 32 ++++--- web/backend/api/gateway_test.go | 100 ++++++++++++++++++--- web/frontend/src/api/gateway.ts | 21 +++-- web/frontend/src/hooks/use-gateway-logs.ts | 4 +- 4 files changed, 126 insertions(+), 31 deletions(-) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 95b482ce0..1813cac92 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -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) } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index fe3fccdee..06803722d 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -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) { diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 1688a5278..9e02a02b5 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -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(path: string, options?: RequestInit): Promise { return res.json() as Promise } -export async function getGatewayStatus(options?: { +export async function getGatewayStatus(): Promise { + return request("/api/gateway/status") +} + +export async function getGatewayLogs(options?: { log_offset?: number log_run_id?: number -}): Promise { +}): Promise { 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(`/api/gateway/status${queryString}`) + return request(`/api/gateway/logs${queryString}`) } export async function startGateway(): Promise { @@ -70,4 +77,8 @@ export async function clearGatewayLogs(): Promise { }) } -export type { GatewayStatusResponse, GatewayActionResponse } +export type { + GatewayStatusResponse, + GatewayLogsResponse, + GatewayActionResponse, +} diff --git a/web/frontend/src/hooks/use-gateway-logs.ts b/web/frontend/src/hooks/use-gateway-logs.ts index 593e90b26..15cbca4ae 100644 --- a/web/frontend/src/hooks/use-gateway-logs.ts +++ b/web/frontend/src/hooks/use-gateway-logs.ts @@ -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, }) From 2f83c185ae5338cba10df19799203e85f86dd956 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:58:34 +0800 Subject: [PATCH 08/11] Fix the issue where the cursor moves inaccurately left and right after entering Chinese when running the picoclaw agent. (#1505) --- cmd/picoclaw/internal/agent/helpers.go | 2 +- go.mod | 6 +++--- go.sum | 9 ++------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index a995945d2..c3ddbb77f 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -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" diff --git a/go.mod b/go.mod index 3762015e9..f29ef7207 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index cdca4fc12..addbab56c 100644 --- a/go.sum +++ b/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= From 4d8fdb0b3d35d0aa2cdf223a92eaa71ce117f2d3 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 13 Mar 2026 19:04:18 +0800 Subject: [PATCH 09/11] feat(web): use a global WebSocket for Pico chat sessions (#1507) - centralize Pico chat connection and session state in a shared store - move chat lifecycle control out of usePicoChat - hydrate and restore the active session across the app --- .../src/components/chat/chat-page.tsx | 5 + web/frontend/src/hooks/use-pico-chat.ts | 442 +----------------- web/frontend/src/lib/pico-chat-controller.ts | 405 ++++++++++++++++ web/frontend/src/lib/pico-chat-state.ts | 59 +++ web/frontend/src/routes/__root.tsx | 6 + web/frontend/src/store/chat.ts | 62 +++ web/frontend/src/store/index.ts | 1 + 7 files changed, 549 insertions(+), 431 deletions(-) create mode 100644 web/frontend/src/lib/pico-chat-controller.ts create mode 100644 web/frontend/src/lib/pico-chat-state.ts create mode 100644 web/frontend/src/store/chat.ts diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 2daeb2e26..1906a0367 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -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) { diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 2b7a510af..1b97a2a9c 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -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 -} - -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([]) - const [connectionState, setConnectionState] = - useState("disconnected") - const [isTyping, setIsTyping] = useState(false) - const [activeSessionId, setActiveSessionId] = useState( - () => readStoredSessionId() || generateSessionId(), - ) - - const wsRef = useRef(null) - const isConnectingRef = useRef(false) - const msgIdCounter = useRef(0) - const activeSessionIdRef = useRef(activeSessionId) - const messagesRevisionRef = useRef(0) - - const setTrackedMessages = useCallback( - (nextState: SetStateAction) => { - 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, } } diff --git a/web/frontend/src/lib/pico-chat-controller.ts b/web/frontend/src/lib/pico-chat-controller.ts new file mode 100644 index 000000000..be3397bae --- /dev/null +++ b/web/frontend/src/lib/pico-chat-controller.ts @@ -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 +} + +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 | null = null +let connectionGeneration = 0 + +async function loadSessionMessages(sessionId: string): Promise { + 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() +} diff --git a/web/frontend/src/lib/pico-chat-state.ts b/web/frontend/src/lib/pico-chat-state.ts new file mode 100644 index 000000000..5b7d6c6cd --- /dev/null +++ b/web/frontend/src/lib/pico-chat-state.ts @@ -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 +} diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index 48f228d84..6431d9490 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -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 ( diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts new file mode 100644 index 000000000..d79a1a93b --- /dev/null +++ b/web/frontend/src/store/chat.ts @@ -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 + +const DEFAULT_CHAT_STATE: ChatStoreState = { + messages: [], + connectionState: "disconnected", + isTyping: false, + activeSessionId: getInitialActiveSessionId(), + hasHydratedActiveSession: false, +} + +export const chatAtom = atom(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 + }) +} diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts index 9dfcdf3c7..d377cdace 100644 --- a/web/frontend/src/store/index.ts +++ b/web/frontend/src/store/index.ts @@ -1 +1,2 @@ export * from "./gateway" +export * from "./chat" From 86da6a7d561d8107a6d75c9a15bccf8ca64e8dfe Mon Sep 17 00:00:00 2001 From: iMil Date: Fri, 13 Mar 2026 12:52:32 +0100 Subject: [PATCH 10/11] #434 added NetBSD support for picoclaw, but since then, picoclaw-launcher{-tui} appeared (#1508) --- .goreleaser.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8d6d046cc..a73f87f30 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -65,6 +65,7 @@ builds: - windows - darwin - freebsd + - netbsd goarch: - amd64 - arm64 @@ -82,6 +83,12 @@ builds: ignore: - goos: windows goarch: arm + - goos: netbsd + goarch: s390x + - goos: netbsd + goarch: mips64 + - goos: netbsd + goarch: arm - id: picoclaw-launcher-tui binary: picoclaw-launcher-tui @@ -96,6 +103,7 @@ builds: - windows - darwin - freebsd + - netbsd goarch: - amd64 - arm64 @@ -113,6 +121,12 @@ builds: ignore: - goos: windows goarch: arm + - goos: netbsd + goarch: s390x + - goos: netbsd + goarch: mips64 + - goos: netbsd + goarch: arm dockers_v2: - id: picoclaw From c68b4f3903418fb1aa947a2b462c3f58ed329e68 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Fri, 13 Mar 2026 23:08:55 +0800 Subject: [PATCH 11/11] fix(qq): populate account bindings metadata (#1456) Co-authored-by: XYSK-lilong007 <267018309+XYSK-lilong007@users.noreply.github.com> --- pkg/channels/qq/qq.go | 7 ++++-- pkg/channels/qq/qq_test.go | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 pkg/channels/qq/qq_test.go diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 73200f64e..4cb4db3c6 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -423,7 +423,9 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { // Reset msg_seq counter for new inbound message. c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) - metadata := map[string]string{} + metadata := map[string]string{ + "account_id": senderID, + } sender := bus.SenderInfo{ Platform: "qq", @@ -495,7 +497,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) metadata := map[string]string{ - "group_id": data.GroupID, + "account_id": senderID, + "group_id": data.GroupID, } sender := bus.SenderInfo{ diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go new file mode 100644 index 000000000..3ceee0d09 --- /dev/null +++ b/pkg/channels/qq/qq_test.go @@ -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") + } +}