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 1/5] 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 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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"