From 26fa98c359a2411b418efcb272bfb0350baa1a7c Mon Sep 17 00:00:00 2001 From: Dimitrij Denissenko Date: Fri, 13 Mar 2026 13:17:38 +0000 Subject: [PATCH 01/72] Align rendering with Matrix' CommonMark guidelines --- pkg/channels/matrix/matrix.go | 5 +- pkg/channels/matrix/matrix_test.go | 101 +++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index bec5dfdac..71561fcc6 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -273,8 +273,9 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { } func markdownToHTML(md string) string { - p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) - renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + extensions := (parser.CommonExtensions | parser.NoEmptyLineBeforeBlock) &^ parser.DefinitionLists + p := parser.NewWithExtensions(extensions) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.UseXHTML}) return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) } diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 07a35c021..1331ed22c 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -340,23 +340,96 @@ func TestMatrixOutboundContent(t *testing.T) { } func TestMarkdownToHTML(t *testing.T) { - tests := []struct { + cases := []struct { name string - input string - contains string + md string + rendered string }{ - {"bold", "**hello**", "hello"}, - {"italic", "_world_", "world"}, - {"header", "### Title", ""}, - {"inline code", "`x`", "x"}, - {"plain text", "just text", "just text"}, + { + name: "paragraph", + md: "just **some** text with _custom_ formatting and `inline` code", + rendered: "

just some text with custom formatting and inline code

", + }, + { + name: "heading", + md: "### Title", + rendered: `

Title

`, + }, + { + name: "fenced code block", + md: "```\nfoo()\n```", + rendered: "
foo()\n
", + }, + { + name: "loose list", + md: "- Item one\n\n- Item two\n", + rendered: ``, + }, + { + name: "tight list", + md: "- Alpha\n- Beta\n", + rendered: ``, + }, + { + name: "list item with nested sublist", + md: "1. Steps overview:\n\n - Step A\n - Step B\n", + rendered: `
    +
  1. Steps overview:

    + +
      +
    • Step A
    • +
    • Step B
    • +
  2. +
`, + }, + { + // Definition list syntax is not enabled; the term and definition are + // rendered as a plain paragraph rather than
/
/
elements. + name: "definition list syntax renders as plain paragraph", + md: "Term\n: Definition of the term.\n", + rendered: "

Term\n: Definition of the term.

", + }, + { + name: "comprehensive document with headings, paragraphs, list, and code block", + md: "# Overview\n\nThis is a sample document designed to demonstrate various Markdown elements in a single block of text.\n\nThe first paragraph introduces the concept of structured data.\n\n## Details\n\nThe following is a list:\n\n* First\n* Second\n* Third\n\nThe second paragraph focuses on details. Below is a generic code snippet:\n\n```python\ndef calculate_area(radius):\n import math\n return math.pi * (radius ** 2)\n```\n\nThis concludes the generic sample text.\n", + rendered: `

Overview

+ +

This is a sample document designed to demonstrate various Markdown elements in a single block of text.

+ +

The first paragraph introduces the concept of structured data.

+ +

Details

+ +

The following is a list:

+ +
    +
  • First
  • +
  • Second
  • +
  • Third
  • +
+ +

The second paragraph focuses on details. Below is a generic code snippet:

+ +
def calculate_area(radius):
+    import math
+    return math.pi * (radius ** 2)
+
+ +

This concludes the generic sample text.

`, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := markdownToHTML(tt.input) - if !strings.Contains(got, tt.contains) { - t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := markdownToHTML(tc.md); got != tc.rendered { + t.Fatalf("markdownToHTML(%q)\n got: %q\nwant: %q", tc.md, got, tc.rendered) } }) } From d08bb02f8fe5092ff02469d10d372f897240504b Mon Sep 17 00:00:00 2001 From: Christoforus Surjoputro Date: Fri, 20 Mar 2026 15:35:18 +0700 Subject: [PATCH 02/72] update restart policy to unless-stopped --- docker/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b26cf4199..0bf46a2ae 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,7 +24,7 @@ services: picoclaw-gateway: image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-gateway - restart: on-failure + restart: unless-stopped profiles: - gateway # Uncomment to access host network; leave commented unless needed. @@ -40,7 +40,7 @@ services: picoclaw-launcher: image: docker.io/sipeed/picoclaw:launcher container_name: picoclaw-launcher - restart: on-failure + restart: unless-stopped profiles: - launcher environment: From ba1538f31d72acd50d1e1acc3bd69187712e9e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Minh=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 21:48:29 +0700 Subject: [PATCH 03/72] Fix: cannot create session github copilot --- pkg/providers/github_copilot_provider.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 6d642b2b5..9e2089219 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -41,8 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi } session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ - Model: model, - Hooks: &copilot.SessionHooks{}, + Model: model,, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{} }) if err != nil { client.Stop() From efbe80691305dbc66f1881a4e103d89f2cd8175e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Minh=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 22:15:13 +0700 Subject: [PATCH 04/72] Fix bug double , --- pkg/providers/github_copilot_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 9e2089219..8748fd384 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -41,7 +41,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi } session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ - Model: model,, + Model: model, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{} }) From e4b104c0de1a259bf0946418dd91003ffe0e66ca Mon Sep 17 00:00:00 2001 From: "enoch.z" Date: Fri, 20 Mar 2026 23:15:18 +0800 Subject: [PATCH 05/72] Update helpers.go correction of the promt for the "picoclaw onboard" command. --- cmd/picoclaw/internal/onboard/helpers.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 6f1d4bdd7..626698fec 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -97,7 +97,11 @@ func onboard(encrypt bool) { fmt.Println("") fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") - fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") + if encrypt { + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") + } else { + fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + } } // promptPassphrase reads the encryption passphrase twice from the terminal From 8f56cceb078a37c55bdf63215ba9c6ae3705ad10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Minh=20=C4=90=E1=BA=A1t?= Date: Fri, 20 Mar 2026 22:17:17 +0700 Subject: [PATCH 06/72] missing , --- pkg/providers/github_copilot_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 8748fd384..e2d1d7d98 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -43,7 +43,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ Model: model, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Hooks: &copilot.SessionHooks{} + Hooks: &copilot.SessionHooks{}, }) if err != nil { client.Stop() From ae021ef843f71ee4e3fc4a85b456912ce9c45350 Mon Sep 17 00:00:00 2001 From: kathent Date: Wed, 25 Mar 2026 10:14:16 +0800 Subject: [PATCH 07/72] fix: more accurate deny pattern for disk wiping --- pkg/tools/shell.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 78ad2b26d..24e2ea1cf 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -35,7 +35,7 @@ var ( regexp.MustCompile(`\brmdir\s+/s\b`), // Match disk wiping commands (must be followed by space/args) regexp.MustCompile( - `\b(format|mkfs|diskpart)\b\s`, + `(^|[^-\w])\b(format|mkfs|diskpart)\b\s`, ), regexp.MustCompile(`\bdd\s+if=`), // Block writes to block devices (all common naming schemes). From f30f57bfabc2d64bd280e28fe5949a2bdf0e958c Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:28:04 +0800 Subject: [PATCH 08/72] docs: add cron job behavior guide --- README.md | 3 + docs/cron.md | 125 ++++++++++++++++++++++++++++++++++++ docs/tools_configuration.md | 5 +- 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 docs/cron.md diff --git a/README.md b/README.md index f627e261e..15aa13ab3 100644 --- a/README.md +++ b/README.md @@ -550,6 +550,8 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too * **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours * **Cron expressions**: "Remind me at 9am daily" -> uses cron expression +See [docs/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details. + ## 📚 Documentation For detailed guides beyond this README: @@ -559,6 +561,7 @@ For detailed guides beyond this README: | [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes | | [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides | | [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox | +| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage | | [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration | | [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | | [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | diff --git a/docs/cron.md b/docs/cron.md new file mode 100644 index 000000000..2ce888df9 --- /dev/null +++ b/docs/cron.md @@ -0,0 +1,125 @@ +# Scheduled Tasks and Cron Jobs + +> Back to [README](../README.md) + +PicoClaw stores scheduled jobs in the current workspace and can run them either as reminders, full agent turns, or shell commands. + +## Schedule Types + +PicoClaw currently uses three schedule forms in the cron tool: + +- `at_seconds`: one-time job, relative to now. After it runs, the job is removed from the store. +- `every_seconds`: recurring interval, in seconds. +- `cron_expr`: recurring cron expression such as `0 9 * * *`. + +The CLI command `picoclaw cron add` currently supports recurring jobs only: + +- `--every ` +- `--cron ''` + +There is no CLI flag for a one-time `at` job today. + +Examples: + +```bash +picoclaw cron add --name "Daily summary" --message "Summarize today's logs" --cron "0 18 * * *" +picoclaw cron add --name "Ping" --message "heartbeat" --every 300 --deliver +``` + +## Execution Modes + +Jobs are stored with a message payload and can execute in three stable user-facing modes: + +### `deliver: false` + +This is the default for the cron tool. + +When the job fires, PicoClaw sends the saved message back through the agent loop as a new agent turn. Use this for scheduled work that may need reasoning, tools, or a generated reply. + +### `deliver: true` + +When the job fires, PicoClaw publishes the saved message directly to the target channel and recipient without agent processing. + +The CLI `picoclaw cron add --deliver` flag uses this mode. + +### `command` + +When a cron-tool job includes `command`, PicoClaw runs that shell command through the `exec` tool and publishes the command output back to the channel. + +For command jobs, `deliver` is forced to `false` when the job is created. The saved `message` becomes descriptive text only; the scheduled action is the shell command. + +The current CLI `picoclaw cron add` command does not expose a `command` flag. + +## Config and Security Gates + +### `tools.cron` + +`tools.cron.enabled` controls whether the agent-facing `cron` tool is registered. Default: `true`. + +If you disable `tools.cron`, users can no longer create or manage jobs through the agent tool. The gateway still starts the cron service and the CLI still uses the same job store, so keep `tools.cron.enabled` on if you expect scheduled agent-turn or command jobs to execute through the gateway. + +`tools.cron.exec_timeout_minutes` sets the timeout used for scheduled command execution. Default: `5`. Set `0` for no timeout. + +### `tools.exec` + +Scheduled command jobs depend on `tools.exec.enabled`. Default: `true`. + +If `tools.exec.enabled` is `false`: + +- new command jobs are rejected by the cron tool +- existing command jobs publish a `command execution is disabled` error when they fire + +`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels. + +### `allow_command` + +`tools.cron.allow_command` defaults to `true`. + +This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`. + +Command jobs also require an internal channel. Non-command reminders do not have that restriction. + +Example: + +```json +{ + "tools": { + "cron": { + "enabled": true, + "exec_timeout_minutes": 5, + "allow_command": true + }, + "exec": { + "enabled": true + } + } +} +``` + +## Persistence and Location + +Cron jobs are stored in: + +```text +/cron/jobs.json +``` + +By default, the workspace is: + +```text +~/.picoclaw/workspace +``` + +If `PICOCLAW_HOME` is set, the default workspace becomes: + +```text +$PICOCLAW_HOME/workspace +``` + +Both the gateway and `picoclaw cron` CLI subcommands use the same `cron/jobs.json` file. + +Notes: + +- one-time `at_seconds` jobs are deleted after they run +- recurring jobs stay in the store until removed +- disabled jobs stay in the store and still appear in `picoclaw cron list` diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index b5907b991..3380a0a67 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -226,8 +226,11 @@ The cron tool is used for scheduling periodic tasks. | Config | Type | Default | Description | |------------------------|------|---------|------------------------------------------------| +| `enabled` | bool | true | Register the agent-facing cron tool | +| `allow_command` | bool | true | Allow command jobs without extra confirmation | | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | -| `allow_command` | bool | false | Allow cron tasks to execute shell commands | + +For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md). ## MCP Tool From 59babde0cf99610817eda0cbaa55d3d2b24bcb33 Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:34:20 +0800 Subject: [PATCH 09/72] docs: clarify cron disabled execution behavior --- docs/cron.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cron.md b/docs/cron.md index 2ce888df9..6483fa137 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -56,7 +56,7 @@ The current CLI `picoclaw cron add` command does not expose a `command` flag. `tools.cron.enabled` controls whether the agent-facing `cron` tool is registered. Default: `true`. -If you disable `tools.cron`, users can no longer create or manage jobs through the agent tool. The gateway still starts the cron service and the CLI still uses the same job store, so keep `tools.cron.enabled` on if you expect scheduled agent-turn or command jobs to execute through the gateway. +If you disable `tools.cron`, users can no longer create or manage jobs through the agent tool. The gateway still starts `CronService`, but it does not install the job execution callback. As a result, due jobs do not actually run; one-time jobs may be deleted and recurring jobs may be rescheduled without executing their payload. The CLI still uses the same job store. `tools.cron.exec_timeout_minutes` sets the timeout used for scheduled command execution. Default: `5`. Set `0` for no timeout. From 6b1d08f4541a4a2355a302c166388da769a1b262 Mon Sep 17 00:00:00 2001 From: Huangting-xy <266665499+Huangting-xy@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:20:54 +0800 Subject: [PATCH 10/72] docs(configuration): add security config reference at document start Add a prominent reference to security_configuration.md at the beginning of the configuration guide. This helps new users quickly find information about storing API keys in .security.yml. Fixes #1986 Co-Authored-By: Claude Opus 4.6 --- docs/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index d39806887..c2a1e123c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,8 @@ Config file: `~/.picoclaw/config.json` +> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](security_configuration.md). + ### Environment Variables You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths. From 8d5fc736d6416967245c9603d784583bbeab6701 Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Fri, 27 Mar 2026 20:04:21 +0700 Subject: [PATCH 11/72] security: add open-by-default warning and '*' allow_from support --- pkg/channels/base.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 882e72d08..f6ea691e7 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -112,6 +112,18 @@ func NewBaseChannel( for _, opt := range opts { opt(bc) } + + // Security Audit: Check for open-by-default (unsecured) channels. + // PicoClaw aims to be secure-by-default. If allow_from is empty, the bot + // currently defaults to accepting messages from ANYONE. To explicitly + // acknowledge and permit this (e.g. for a public bot), use ["*"]. + if len(bc.allowList) == 0 { + logger.WarnCF("channels", "SECURITY: Channel allows EVERYONE (allow_from is empty)", map[string]any{ + "channel": bc.name, + "hint": "Set allow_from to your ID, or use '*' to explicitly acknowledge open access.", + }) + } + return bc } @@ -187,6 +199,9 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { } for _, allowed := range c.allowList { + if allowed == "*" { + return true + } // Strip leading "@" from allowed value for username matching trimmed := strings.TrimPrefix(allowed, "@") allowedID := trimmed @@ -221,7 +236,7 @@ func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { } for _, allowed := range c.allowList { - if identity.MatchAllowed(sender, allowed) { + if allowed == "*" || identity.MatchAllowed(sender, allowed) { return true } } From e23eda53655c43998b14a1658f2fec709e61d2f6 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Sat, 28 Mar 2026 13:33:48 +0000 Subject: [PATCH 12/72] Update tests and error cases handling --- pkg/providers/azure/provider.go | 3 +- pkg/providers/azure/provider_test.go | 37 +++++++++++ .../responses_common.go | 7 +- .../responses_common_test.go | 64 +++++++++++++------ 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go index 9d29a90cd..429b26798 100644 --- a/pkg/providers/azure/provider.go +++ b/pkg/providers/azure/provider.go @@ -26,6 +26,7 @@ type ( const ( defaultRequestTimeout = common.DefaultRequestTimeout + responsesAPIPath = "openai/v1/responses" ) // Provider implements the LLM provider interface for Azure OpenAI endpoints. @@ -87,7 +88,7 @@ func (p *Provider) Chat( return nil, fmt.Errorf("Azure API base not configured") } - requestURL, err := url.JoinPath(p.apiBase, "openai/v1/responses") + requestURL, err := url.JoinPath(p.apiBase, responsesAPIPath) if err != nil { return nil, fmt.Errorf("failed to build Azure request URL: %w", err) } diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go index e57d68057..b3752ea50 100644 --- a/pkg/providers/azure/provider_test.go +++ b/pkg/providers/azure/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -167,6 +168,42 @@ func TestProviderChat_AzureHTTPError(t *testing.T) { } } +func TestProviderChat_AzureRateLimitError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}`)) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for 429, got nil") + } + if !strings.Contains(err.Error(), "429") { + t.Errorf("error should contain status code 429, got: %v", err) + } +} + +func TestProviderChat_AzureServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Internal server error","type":"server_error"}}`)) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should contain status code 500, got: %v", err) + } +} + func TestProviderChat_AzureParseTextOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 29133a51e..839471f69 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -268,8 +268,13 @@ func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse { if len(toolCalls) > 0 { finishReason = "tool_calls" } - if apiResp.Status == "incomplete" { + switch apiResp.Status { + case responses.ResponseStatusIncomplete: finishReason = "length" + case responses.ResponseStatusFailed: + finishReason = "error" + case responses.ResponseStatusCancelled: + finishReason = "canceled" } var usage *protocoltypes.UsageInfo diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index be10e8427..0d41190b1 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -2,9 +2,12 @@ package openai_responses_common import ( "encoding/json" + "fmt" "strings" "testing" + "github.com/openai/openai-go/v3/responses" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -298,10 +301,10 @@ func TestTranslateTools_DescriptionOmittedWhenEmpty(t *testing.T) { // --- ParseResponseBody tests --- func TestParseResponseBody_TextOutput(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_123", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "message", @@ -315,7 +318,7 @@ func TestParseResponseBody_TextOutput(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -333,10 +336,10 @@ func TestParseResponseBody_TextOutput(t *testing.T) { } func TestParseResponseBody_FunctionCall(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_456", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "function_call", @@ -352,7 +355,7 @@ func TestParseResponseBody_FunctionCall(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -373,10 +376,10 @@ func TestParseResponseBody_FunctionCall(t *testing.T) { } func TestParseResponseBody_Reasoning(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_789", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "reasoning", @@ -395,7 +398,7 @@ func TestParseResponseBody_Reasoning(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 10} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -410,10 +413,10 @@ func TestParseResponseBody_Reasoning(t *testing.T) { } func TestParseResponseBody_Refusal(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_ref", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "message", @@ -427,7 +430,7 @@ func TestParseResponseBody_Refusal(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -439,10 +442,10 @@ func TestParseResponseBody_Refusal(t *testing.T) { } func TestParseResponseBody_IncompleteStatus(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_inc", "object": "response", - "status": "incomplete", + "status": "%s", "output": [ { "type": "message", @@ -452,7 +455,7 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}} - }`) + }`, string(responses.ResponseStatusIncomplete))) result, err := ParseResponseBody(body) if err != nil { @@ -464,23 +467,42 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { } func TestParseResponseBody_FailedStatus(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_fail", "object": "response", - "status": "failed", + "status": "%s", "output": [], "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}} - }`) + }`, string(responses.ResponseStatusFailed))) result, err := ParseResponseBody(body) if err != nil { t.Fatalf("error: %v", err) } - // failed/canceled statuses are not specially mapped; they fall through to "stop" - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + if result.FinishReason != "error" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "error") + } +} + +func TestParseResponseBody_CanceledStatus(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_cancel", + "object": "response", + "status": "%s", + "output": [], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}} + }`, string(responses.ResponseStatusCancelled))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("error: %v", err) + } + if result.FinishReason != "canceled" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "canceled") } } From 43095543ab28d45b3fd48112caa42dec95001dfb Mon Sep 17 00:00:00 2001 From: imalasong Date: Sat, 28 Mar 2026 23:36:49 +0800 Subject: [PATCH 13/72] fix(feishu): skip empty random_reaction_emoji entries Feishu returns 231001 when emoji_type is empty. Config slices like ["", "Pin"] could randomly select an empty string; filter and trim entries and fall back to Pin when none remain. Made-with: Cursor --- pkg/channels/feishu/feishu_64.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5c57cfb02..d0c351119 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -245,15 +245,18 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { - // Get emoji list from config - emojiList := c.config.RandomReactionEmoji - var chosenEmoji string - if len(emojiList) == 0 { - // Default to "Pin" if no config - chosenEmoji = "Pin" - } else { - idx := rand.Intn(len(emojiList)) - chosenEmoji = emojiList[idx] + // Get emoji list from config (Feishu emoji_type keys, e.g. Pin, THUMBSUP). + // Ignore empty entries so a list like ["", "Pin"] does not randomly pick "" (API 231001). + var candidates []string + for _, e := range c.config.RandomReactionEmoji { + e = strings.TrimSpace(e) + if e != "" { + candidates = append(candidates, e) + } + } + chosenEmoji := "Pin" + if len(candidates) > 0 { + chosenEmoji = candidates[rand.Intn(len(candidates))] } req := larkim.NewCreateMessageReactionReqBuilder(). From 0bb561548f099a60b588f0aaef30615af45aad05 Mon Sep 17 00:00:00 2001 From: Cytown Date: Sun, 29 Mar 2026 01:14:39 +0800 Subject: [PATCH 14/72] add pid file for gateway running and auth token for /reload and pico channel --- cmd/picoclaw-launcher-tui/ui/gateway.go | 72 ++---- cmd/picoclaw/internal/helpers.go | 6 +- pkg/agent/context.go | 10 +- pkg/auth/store.go | 7 +- pkg/channels/weixin/state.go | 6 +- pkg/config/config.go | 7 +- pkg/config/defaults.go | 12 +- pkg/config/envkeys.go | 20 ++ pkg/gateway/gateway.go | 40 ++- pkg/health/server.go | 36 ++- pkg/health/server_test.go | 11 +- pkg/migrate/internal/common.go | 11 +- pkg/pid/pidfile.go | 159 ++++++++++++ pkg/pid/pidfile_test.go | 253 +++++++++++++++++++ pkg/pid/pidfile_unix.go | 22 ++ pkg/pid/pidfile_windows.go | 42 ++++ web/backend/api/config.go | 6 + web/backend/api/gateway.go | 310 +++++++++++++++--------- web/backend/api/gateway_test.go | 3 - web/backend/api/pico.go | 61 ++++- web/backend/api/pico_test.go | 14 ++ web/backend/api/skills.go | 9 +- web/backend/middleware/middleware.go | 11 + web/backend/utils/runtime.go | 8 +- 24 files changed, 876 insertions(+), 260 deletions(-) create mode 100644 pkg/pid/pidfile.go create mode 100644 pkg/pid/pidfile_test.go create mode 100644 pkg/pid/pidfile_unix.go create mode 100644 pkg/pid/pidfile_windows.go diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go index 1138c12db..781204bf2 100644 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -7,9 +7,7 @@ package ui import ( "fmt" - "os" "os/exec" - "path/filepath" "runtime" "strconv" "strings" @@ -17,61 +15,30 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" -) -const pidFileName = "gateway.pid" + "github.com/sipeed/picoclaw/pkg/config" + ppid "github.com/sipeed/picoclaw/pkg/pid" +) type gatewayStatus struct { running bool pid int + version string } -func getPidPath() string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".picoclaw", pidFileName) -} - -func isProcessRunning(pid int) bool { - if runtime.GOOS == "windows" { - cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) - output, err := cmd.Output() - if err != nil { - return false - } - return strings.Contains(string(output), strconv.Itoa(pid)) - } else if runtime.GOOS == "darwin" { - cmd := exec.Command("ps", "aux") - output, err := cmd.Output() - if err != nil { - return false - } - return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) - } - // Linux - _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) - return err == nil +func picoHome() string { + return config.GetHome() } func getGatewayStatus() gatewayStatus { - pidPath := getPidPath() - data, err := os.ReadFile(pidPath) - if err != nil { - return gatewayStatus{running: false} - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil { - return gatewayStatus{running: false} - } - if !isProcessRunning(pid) { - os.Remove(pidPath) + data := ppid.ReadPidFileWithCheck(picoHome()) + if data == nil { return gatewayStatus{running: false} } return gatewayStatus{ running: true, - pid: pid, + pid: data.PID, + version: data.Version, } } @@ -81,13 +48,12 @@ func startGateway() error { return fmt.Errorf("gateway is already running (PID: %d)", status.pid) } - pidPath := getPidPath() var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1") } else { - cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath) + cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &") } err := cmd.Start() @@ -116,9 +82,8 @@ func startGateway() error { if line == "" { continue } - pid, err := strconv.Atoi(line) + _, err := strconv.Atoi(line) if err == nil { - os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600) break } } @@ -141,21 +106,20 @@ func stopGateway() error { if runtime.GOOS == "windows" { err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run() } else { - err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run() + err = exec.Command("kill", strconv.Itoa(status.pid)).Run() } if err != nil { return err } - // 多次尝试确认进程已停止 + // Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file) for i := 0; i < 5; i++ { - if !isProcessRunning(status.pid) { + if !getGatewayStatus().running { break } time.Sleep(200 * time.Millisecond) } - os.Remove(getPidPath()) return nil } @@ -217,7 +181,11 @@ func (a *App) newGatewayPage() tview.Primitive { updateStatus = func() { status := getGatewayStatus() if status.running { - statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid)) + versionInfo := "" + if status.version != "" { + versionInfo = fmt.Sprintf("\nVersion: %s", status.version) + } + statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo)) buttons.SetItemText(0, " [gray]START[white] ", "") buttons.SetItemText(1, " [red]STOP[white] ", "") } else { diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index 17de88ccb..afe5074a7 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -14,11 +14,7 @@ const Logo = pkg.Logo // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, _ := os.UserHomeDir() - return filepath.Join(home, pkg.DefaultPicoClawHome) + return config.GetHome() } func GetConfigPath() string { diff --git a/pkg/agent/context.go b/pkg/agent/context.go index c3fcc9fff..b5c68650a 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -12,7 +12,6 @@ import ( "sync" "time" - "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -59,14 +58,7 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { } func getGlobalConfigDir() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, pkg.DefaultPicoClawHome) + return config.GetHome() } func NewContextBuilder(workspace string) *ContextBuilder { diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 8a878d553..dfea11df4 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -6,7 +6,6 @@ import ( "path/filepath" "time" - "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -41,11 +40,7 @@ func (c *AuthCredential) NeedsRefresh() bool { } func authFilePath() string { - if home := os.Getenv(config.EnvHome); home != "" { - return filepath.Join(home, "auth.json") - } - home, _ := os.UserHomeDir() - return filepath.Join(home, pkg.DefaultPicoClawHome, "auth.json") + return filepath.Join(config.GetHome(), "auth.json") } func LoadStore() (*AuthStore, error) { diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go index 2d1b9f4a6..854a4ab53 100644 --- a/pkg/channels/weixin/state.go +++ b/pkg/channels/weixin/state.go @@ -37,11 +37,7 @@ type syncCursorFile struct { } func picoclawHomeDir() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - userHome, _ := os.UserHomeDir() - return filepath.Join(userHome, ".picoclaw") + return config.GetHome() } func buildWeixinSyncBufPath(cfg config.WeixinConfig) string { diff --git a/pkg/config/config.go b/pkg/config/config.go index 533f45a44..e7326625c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1081,12 +1081,7 @@ func LoadConfig(path string) (*Config, error) { // Ensure Workspace has a default if not set if cfg.Agents.Defaults.Workspace == "" { - homePath, _ := os.UserHomeDir() - if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { - homePath = picoclawHome - } else if homePath != "" { - homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome) - } + homePath := GetHome() cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName) } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index bc4ab0649..bded97fcd 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -6,7 +6,6 @@ package config import ( - "os" "path/filepath" "github.com/sipeed/picoclaw/pkg" @@ -14,16 +13,7 @@ import ( // DefaultConfig returns the default configuration for PicoClaw. func DefaultConfig() *Config { - // Determine the base path for the workspace. - // Priority: $PICOCLAW_HOME > ~/.picoclaw - var homePath string - if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { - homePath = picoclawHome - } else { - userHome, _ := os.UserHomeDir() - homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome) - } - workspacePath := filepath.Join(homePath, pkg.WorkspaceName) + workspacePath := filepath.Join(GetHome(), pkg.WorkspaceName) return &Config{ Version: CurrentVersion, diff --git a/pkg/config/envkeys.go b/pkg/config/envkeys.go index b04ff19f5..615769d3c 100644 --- a/pkg/config/envkeys.go +++ b/pkg/config/envkeys.go @@ -5,6 +5,13 @@ package config +import ( + "os" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg" +) + // Runtime environment variable keys for the picoclaw process. // These control the location of files and binaries at runtime and are read // directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the @@ -35,3 +42,16 @@ const ( // Default: "127.0.0.1" EnvGatewayHost = "PICOCLAW_GATEWAY_HOST" ) + +func GetHome() string { + homePath, _ := os.UserHomeDir() + if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { + homePath = picoclawHome + } else if homePath != "" { + homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome) + } + if homePath == "" { + homePath = "." + } + return homePath +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index c35b3e744..a63530806 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "sync" "sync/atomic" "syscall" @@ -36,6 +37,7 @@ import ( "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/pid" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" @@ -61,6 +63,7 @@ type services struct { HealthServer *health.Server manualReloadChan chan struct{} reloading atomic.Bool + authToken string } type startupBlockedProvider struct { @@ -107,6 +110,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error fmt.Println("🔍 Debug mode enabled") } + // Enforce singleton: write PID file with generated token. + pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port) + if err != nil { + return fmt.Errorf("singleton check failed: %w", err) + } + defer pid.RemovePidFile(homePath) + provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) if err != nil { return fmt.Errorf("error creating provider: %w", err) @@ -133,7 +143,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error "skills_available": skillsInfo["available"], }) - runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus) + runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token) if err != nil { return err } @@ -224,6 +234,9 @@ func executeReload( allowEmptyStartup bool, ) error { defer runningServices.reloading.Store(false) + + overridePicoToken(newCfg, runningServices.authToken) + return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup) } @@ -248,6 +261,7 @@ func setupAndStartServices( cfg *config.Config, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, + authToken string, ) (*services, error) { runningServices := &services{} @@ -290,6 +304,8 @@ func setupAndStartServices( fms.Start() } + overridePicoToken(cfg, authToken) + runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { @@ -314,7 +330,8 @@ func setupAndStartServices( } addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) - runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + runningServices.authToken = authToken + runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { @@ -524,6 +541,9 @@ func restartServices( logger.InfoCF("voice", "Transcription disabled", nil) } + // NOTE: PID file is written once at startup and not updated on reload. + // Changing the gateway listen address requires a full restart. + return nil } @@ -642,6 +662,22 @@ func setupCronTool( return cronService, nil } +const picoTokenPrefix = "pico-" + +// overridePicoToken replaces the pico channel token with the one from the PID file. +// The PID file is the single source of truth for the pico auth token; +// it is generated once at gateway startup and remains unchanged across reloads. +func overridePicoToken(cfg *config.Config, token string) { + if !cfg.Channels.Pico.Enabled { + return + } + picoToken := cfg.Channels.Pico.Token.String() + if picoToken == "" || strings.HasPrefix(picoToken, picoTokenPrefix) { + return + } + cfg.Channels.Pico.SetToken(picoTokenPrefix + token + picoToken) +} + func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult { if channel == "" || chatID == "" { diff --git a/pkg/health/server.go b/pkg/health/server.go index 387cb0756..2602cb965 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,11 +2,11 @@ package health import ( "context" + "crypto/subtle" "encoding/json" "fmt" "maps" "net/http" - "os" "sync" "time" ) @@ -18,6 +18,7 @@ type Server struct { checks map[string]Check startTime time.Time reloadFunc func() error + authToken string // optional bearer token for protected endpoints } type Check struct { @@ -31,15 +32,15 @@ type StatusResponse struct { Status string `json:"status"` Uptime string `json:"uptime"` Checks map[string]Check `json:"checks,omitempty"` - Pid int `json:"pid"` } -func NewServer(host string, port int) *Server { +func NewServer(host string, port int, token string) *Server { mux := http.NewServeMux() s := &Server{ ready: false, checks: make(map[string]Check), startTime: time.Now(), + authToken: token, } mux.HandleFunc("/health", s.healthHandler) @@ -123,6 +124,21 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { return } + // Token check + s.mu.RLock() + requiredToken := s.authToken + s.mu.RUnlock() + + if requiredToken != "" { + given := extractBearerToken(r.Header.Get("Authorization")) + if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + } + s.mu.Lock() reloadFunc := s.reloadFunc s.mu.Unlock() @@ -154,7 +170,6 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { resp := StatusResponse{ Status: "ok", Uptime: uptime.String(), - Pid: os.Getpid(), } json.NewEncoder(w).Encode(resp) @@ -220,3 +235,16 @@ func statusString(ok bool) string { } return "fail" } + +// extractBearerToken returns the token from an "Authorization: Bearer " header, +// or the empty string if the header is missing or malformed. +func extractBearerToken(header string) string { + const prefix = "Bearer " + if len(header) < len(prefix) { + return "" + } + if header[:len(prefix)] != prefix { + return "" + } + return header[len(prefix):] +} diff --git a/pkg/health/server_test.go b/pkg/health/server_test.go index 6e0b5e66b..c4982fff9 100644 --- a/pkg/health/server_test.go +++ b/pkg/health/server_test.go @@ -15,6 +15,7 @@ func newTestServer() *Server { ready: false, checks: make(map[string]Check), startTime: time.Now(), + authToken: "test", } return s } @@ -37,9 +38,6 @@ func TestHealthHandler_ReturnsOK(t *testing.T) { if resp.Status != "ok" { t.Errorf("status = %q, want %q", resp.Status, "ok") } - if resp.Pid == 0 { - t.Error("pid should not be 0") - } if resp.Uptime == "" { t.Error("uptime should not be empty") } @@ -168,6 +166,7 @@ func TestReloadHandler_NoReloadFunc(t *testing.T) { s := newTestServer() req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") w := httptest.NewRecorder() s.reloadHandler(w, req) @@ -186,6 +185,7 @@ func TestReloadHandler_Success(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") w := httptest.NewRecorder() s.reloadHandler(w, req) @@ -205,6 +205,7 @@ func TestReloadHandler_Error(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") w := httptest.NewRecorder() s.reloadHandler(w, req) @@ -292,7 +293,7 @@ func TestRegisterOnMux(t *testing.T) { } func TestNewServer(t *testing.T) { - s := NewServer("127.0.0.1", 0) + s := NewServer("127.0.0.1", 0, "") if s == nil { t.Fatal("NewServer returned nil") } @@ -305,7 +306,7 @@ func TestNewServer(t *testing.T) { } func TestStartContext_Cancellation(t *testing.T) { - s := NewServer("127.0.0.1", 0) + s := NewServer("127.0.0.1", 0, "") ctx, cancel := context.WithCancel(context.Background()) diff --git a/pkg/migrate/internal/common.go b/pkg/migrate/internal/common.go index 65a87adc4..f1179c3a9 100644 --- a/pkg/migrate/internal/common.go +++ b/pkg/migrate/internal/common.go @@ -1,12 +1,10 @@ package internal import ( - "fmt" "io" "os" "path/filepath" - "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/config" ) @@ -14,14 +12,7 @@ func ResolveTargetHome(override string) (string, error) { if override != "" { return ExpandHome(override), nil } - if envHome := os.Getenv(config.EnvHome); envHome != "" { - return ExpandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, pkg.DefaultPicoClawHome), nil + return config.GetHome(), nil } func ExpandHome(path string) string { diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go new file mode 100644 index 000000000..584b9b2b5 --- /dev/null +++ b/pkg/pid/pidfile.go @@ -0,0 +1,159 @@ +package pid + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const pidFileName = ".picoclaw.pid" + +// PidFileData is the JSON structure stored in the PID file. +type PidFileData struct { + PID int `json:"pid"` + Token string `json:"token"` + Version string `json:"version"` + Port int `json:"port"` + Host string `json:"host"` +} + +var pidMu sync.Mutex + +// pidFilePath returns the absolute path for the PID file given the home directory. +func pidFilePath(homePath string) string { + return filepath.Join(homePath, pidFileName) +} + +// generateToken creates a cryptographically random 32-character hex token. +func generateToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to something pseudo-random if crypto/rand fails + return fmt.Sprintf("%032x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// WritePidFile creates (or overwrites) the PID file atomically. +// It returns an error if another gateway instance appears to be running +// (a valid PID file exists with a live process). +func WritePidFile(homePath, host string, port int) (*PidFileData, error) { + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + + // Check for existing PID file → singleton enforcement. + if data, err := readPidFileUnlocked(pidPath); err == nil { + if os.Getpid() != data.PID { + logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version) + if isProcessRunning(data.PID) { + return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version) + } + logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath) + } + // Stale PID file; process no longer exists → clean up. + os.Remove(pidPath) + } + + data := &PidFileData{ + PID: os.Getpid(), + Version: config.GetVersion(), + Port: port, + Host: host, + } + + token := generateToken() + data.Token = token + + raw, err := json.MarshalIndent(data, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal pid file: %w", err) + } + + // Ensure parent directory exists. + dir := filepath.Dir(pidPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create pid directory: %w", err) + } + + // Write atomically via temp file + rename. + tmp := pidPath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return nil, fmt.Errorf("failed to write pid file: %w", err) + } + if err := os.Rename(tmp, pidPath); err != nil { + os.Remove(tmp) + return nil, fmt.Errorf("failed to rename pid file: %w", err) + } + + return data, nil +} + +// ReadPidFileWithCheck reads the PID file and additionally checks if +// the recorded process is still alive. Returns nil if the file is +// missing, unreadable, or the process has exited. +func ReadPidFileWithCheck(homePath string) *PidFileData { + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + data, err := readPidFileUnlocked(pidPath) + if err != nil { + return nil + } + + if !isProcessRunning(data.PID) { + os.Remove(pidPath) + return nil + } + + return data +} + +// RemovePidFile deletes the PID file (e.g. on graceful shutdown). +func RemovePidFile(homePath string) { + pidMu.Lock() + defer pidMu.Unlock() + + pidPath := pidFilePath(homePath) + // Only remove if the PID matches our own process (avoid deleting + // a file that belongs to a newer gateway instance). + if data, err := readPidFileUnlocked(pidPath); err == nil { + if data.PID != os.Getpid() { + return + } + } + + logger.Infof("remove pid file: %s", pidPath) + os.Remove(pidPath) +} + +// readPidFileUnlocked reads the PID file without acquiring the lock. +// Caller must hold pidMu. +func readPidFileUnlocked(pidPath string) (*PidFileData, error) { + raw, err := os.ReadFile(pidPath) + if err != nil { + return nil, err + } + + var data PidFileData + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + + // Validate PID is a positive integer. + if data.PID <= 0 { + return nil, fmt.Errorf("invalid pid in pid file: %d", data.PID) + } + + return &data, nil +} diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go new file mode 100644 index 000000000..921f590ad --- /dev/null +++ b/pkg/pid/pidfile_test.go @@ -0,0 +1,253 @@ +package pid + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// tmpDir returns a clean temporary directory for a test. +func tmpDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "pidtest-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +// TestGenerateToken verifies that generateToken produces a 32-character hex string. +func TestGenerateToken(t *testing.T) { + token := generateToken() + if len(token) != 32 { + t.Errorf("expected token length 32, got %d (token: %q)", len(token), token) + } + // Verify all characters are valid hex. + for _, c := range token { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("token contains non-hex character: %c", c) + } + } +} + +// TestGenerateTokenUniqueness checks that two consecutive tokens differ. +func TestGenerateTokenUniqueness(t *testing.T) { + a := generateToken() + b := generateToken() + if a == b { + t.Error("two consecutive tokens should not be equal") + } +} + +// TestPidFilePath returns the expected path. +func TestPidFilePath(t *testing.T) { + dir := tmpDir(t) + got := pidFilePath(dir) + want := filepath.Join(dir, pidFileName) + if got != want { + t.Errorf("pidFilePath(%q) = %q, want %q", dir, got, want) + } +} + +// TestWritePidFile creates a PID file and verifies its contents. +func TestWritePidFile(t *testing.T) { + dir := tmpDir(t) + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile failed: %v", err) + } + + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } + if data.Host != "127.0.0.1" { + t.Errorf("Host = %q, want %q", data.Host, "127.0.0.1") + } + if data.Port != 18790 { + t.Errorf("Port = %d, want %d", data.Port, 18790) + } + if len(data.Token) != 32 { + t.Errorf("Token length = %d, want 32", len(data.Token)) + } + + // Verify the file exists and can be unmarshalled. + raw, err := os.ReadFile(filepath.Join(dir, pidFileName)) + if err != nil { + t.Fatalf("failed to read pid file: %v", err) + } + + var fileData PidFileData + if err = json.Unmarshal(raw, &fileData); err != nil { + t.Fatalf("failed to unmarshal pid file: %v", err) + } + if fileData.PID != data.PID || fileData.Token != data.Token { + t.Error("file data mismatch") + } + + // Verify file permissions (owner-only read/write). + info, err := os.Stat(filepath.Join(dir, pidFileName)) + if err != nil { + t.Fatalf("failed to stat pid file: %v", err) + } + perm := info.Mode().Perm() + if perm != 0o600 { + t.Errorf("file permission = %o, want 0600", perm) + } +} + +// TestWritePidFileOverwrite writes twice and verifies the PID file is replaced. +func TestWritePidFileOverwrite(t *testing.T) { + dir := tmpDir(t) + + data1, err := WritePidFile(dir, "0.0.0.0", 18790) + if err != nil { + t.Fatalf("first WritePidFile failed: %v", err) + } + + // Second write should succeed because the PID matches our process. + data2, err := WritePidFile(dir, "0.0.0.0", 18800) + if err != nil { + t.Fatalf("second WritePidFile failed: %v", err) + } + + if data2.Token == data1.Token { + t.Error("token should change on re-write") + } + if data2.Port != 18800 { + t.Errorf("Port = %d, want 18800", data2.Port) + } +} + +// TestWritePidFileStalePID writes a PID file with a non-running PID, then +// verifies WritePidFile cleans it up and writes a new one. +func TestWritePidFileStalePID(t *testing.T) { + dir := tmpDir(t) + + // Write a PID file with a PID that almost certainly doesn't exist. + stale := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile with stale PID failed: %v", err) + } + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } +} + +// TestReadPidFileWithCheck verifies reading a valid PID file for the current process. +func TestReadPidFileWithCheck(t *testing.T) { + dir := tmpDir(t) + + // Some sandboxed environments (e.g. macOS test runner) may restrict + // signal(0), causing isProcessRunning(getpid()) to return false. + if !isProcessRunning(os.Getpid()) { + t.Skip("skipping: isProcessRunning(getpid()) is false in this environment") + } + + written, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile failed: %v", err) + } + + read := ReadPidFileWithCheck(dir) + if read == nil { + t.Fatal("ReadPidFileWithCheck returned nil for current process") + } + if read.PID != written.PID || read.Token != written.Token { + t.Error("read data doesn't match written data") + } +} + +// TestReadPidFileWithCheckNonexistent returns nil for missing file. +func TestReadPidFileWithCheckNonexistent(t *testing.T) { + dir := tmpDir(t) + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for nonexistent PID file") + } +} + +// TestReadPidFileWithCheckStalePID auto-cleans a PID file whose process is dead. +func TestReadPidFileWithCheckStalePID(t *testing.T) { + dir := tmpDir(t) + + stale := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for stale PID") + } + + // File should be cleaned up. + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("stale PID file should be removed") + } +} + +// TestRemovePidFile removes the PID file for the current process. +func TestRemovePidFile(t *testing.T) { + dir := tmpDir(t) + + if _, err := WritePidFile(dir, "127.0.0.1", 18790); err != nil { + t.Fatalf("WritePidFile failed: %v", err) + } + + RemovePidFile(dir) + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("PID file should be removed") + } +} + +// TestRemovePidFileDifferentPID does not remove a PID file owned by another process. +func TestRemovePidFileDifferentPID(t *testing.T) { + dir := tmpDir(t) + + other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(other, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + RemovePidFile(dir) + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); os.IsNotExist(err) { + t.Error("PID file should NOT be removed (different PID)") + } +} + +// TestRemovePidFileNonexistent does not error on missing file. +func TestRemovePidFileNonexistent(t *testing.T) { + dir := tmpDir(t) + // Should not panic or error. + RemovePidFile(dir) +} + +// TestReadPidFileUnlockedInvalidJSON returns error for malformed content. +func TestReadPidFileUnlockedInvalidJSON(t *testing.T) { + dir := tmpDir(t) + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, []byte("not json"), 0o600) + + _, err := readPidFileUnlocked(path) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// TestReadPidFileUnlockedInvalidPID returns error for non-positive PID. +func TestReadPidFileUnlockedInvalidPID(t *testing.T) { + dir := tmpDir(t) + path := filepath.Join(dir, pidFileName) + os.WriteFile(path, []byte(`{"pid": -1, "token": "a"}`), 0o600) + + _, err := readPidFileUnlocked(path) + if err == nil { + t.Error("expected error for invalid PID") + } +} diff --git a/pkg/pid/pidfile_unix.go b/pkg/pid/pidfile_unix.go new file mode 100644 index 000000000..5459d8370 --- /dev/null +++ b/pkg/pid/pidfile_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package pid + +import ( + "os" + "syscall" +) + +// isProcessRunning checks whether a process with the given PID is alive +// on Unix-like systems using signal(0). +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + p, err := os.FindProcess(pid) + if err != nil { + return false + } + // Signal(nil) does not kill the process but checks existence on Unix. + return p.Signal(syscall.Signal(0)) == nil +} diff --git a/pkg/pid/pidfile_windows.go b/pkg/pid/pidfile_windows.go new file mode 100644 index 000000000..6a2cce793 --- /dev/null +++ b/pkg/pid/pidfile_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package pid + +import ( + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess") + procCloseHandle = kernel32.NewProc("CloseHandle") + processQueryLimitedInformation = uint32(0x1000) + stillActive = uint32(259) +) + +// isProcessRunning checks whether a process with the given PID is alive +// on Windows using OpenProcess + GetExitCodeProcess. +func isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + handle, _, err := procOpenProcess.Call( + uintptr(processQueryLimitedInformation), + 0, + uintptr(pid), + ) + if handle == 0 || err != nil { + return false + } + defer procCloseHandle.Call(handle) + + var exitCode uint32 + ret, _, err := procGetExitCodeProcess.Call(handle, uintptr(unsafe.Pointer(&exitCode))) + if ret == 0 || err != nil { + return false + } + return exitCode == stillActive +} diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 0add7594d..8da2f53e0 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -81,6 +81,9 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } + // Refresh cached pico token in case user changed it. + refreshPicoToken(&cfg) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } @@ -175,6 +178,9 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } + // Refresh cached pico token in case user changed it. + refreshPicoToken(&newCfg) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 808475cb6..4aebd2eaa 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -20,6 +20,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" + ppid "github.com/sipeed/picoclaw/pkg/pid" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -32,11 +33,50 @@ var gateway = struct { runtimeStatus string startupDeadline time.Time logs *LogBuffer + pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json + picoToken string // cached pico token from config (for proxy auth validation) }{ runtimeStatus: "stopped", logs: NewLogBuffer(200), } +// refreshPicoToken updates gateway.picoToken from cfg +func refreshPicoToken(cfg *config.Config) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + gateway.picoToken = cfg.Channels.Pico.Token.String() +} + +// refreshPicoTokensLocked reads the pico token from config and caches it. +// Caller must hold gateway.mu (or be sole writer). +func refreshPicoTokensLocked(configPath string) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return + } + gateway.picoToken = cfg.Channels.Pico.Token.String() +} + +const ( + protocolKey = "Sec-Websocket-Protocol" + picoTokenPrefix = "pico-" + tokenPrefix = "token." +) + +// picoComposedToken returns "pico-"+pidToken+picoToken for gateway auth. +func picoComposedToken(token string) string { + gateway.mu.Lock() + defer gateway.mu.Unlock() + // if not initial pico token, don't allow gateway auth + if gateway.picoToken == "" || gateway.pidData == nil { + return "" + } + if tokenPrefix+gateway.picoToken != token { + return "" + } + return picoTokenPrefix + gateway.pidData.Token + gateway.picoToken +} + var ( gatewayStartupWindow = 15 * time.Second gatewayRestartGracePeriod = 5 * time.Second @@ -49,16 +89,29 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, return client.Get(url) } -// getGatewayHealth checks the gateway health endpoint and returns the status response +// getGatewayHealth checks the gateway health endpoint and returns the status response. // Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid. func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) { - port := 18790 - if cfg != nil && cfg.Gateway.Port != 0 { - port = cfg.Gateway.Port + // Prefer port/host from pidData when available. + var port int + var host string + gateway.mu.Lock() + if d := gateway.pidData; d != nil && d.Port > 0 { + port = d.Port + host = d.Host + } + gateway.mu.Unlock() + if port == 0 { + port = 18790 + if cfg != nil && cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + } + if host == "" { + host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) } - probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) - url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health" + url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health" return getGatewayHealthByURL(url, timeout) } @@ -91,30 +144,34 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { // TryAutoStartGateway checks whether gateway start preconditions are met and // starts it when possible. Intended to be called by the backend at startup. func (h *Handler) TryAutoStartGateway() { - // Check if gateway is already running via health endpoint - cfg, cfgErr := config.LoadConfig(h.configPath) - if cfgErr == nil && cfg != nil { - healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err == nil && statusCode == http.StatusOK { - // Gateway is already running, attach to the existing process - pid := healthResp.Pid - gateway.mu.Lock() - defer gateway.mu.Unlock() - ready, reason, err := h.gatewayStartReady() - if err != nil { - logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) - return - } - if !ready { - logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) - return - } - _, err = h.startGatewayLocked("starting", pid) - if err != nil { - logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) - } + // Check PID file first to detect an already-running gateway. + pidData := ppid.ReadPidFileWithCheck(globalConfigDir()) + logger.Infof("pidData: %v", pidData) + if pidData != nil { + gateway.mu.Lock() + ready, reason, err := h.gatewayStartReady() + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err)) + gateway.mu.Unlock() return } + logger.Infof("ready: %v, reason: %s", ready, reason) + if !ready { + logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason)) + gateway.mu.Unlock() + return + } + pid := pidData.PID + _, err = h.startGatewayLocked("starting", pid) + if err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) + } else { + gateway.pidData = pidData + refreshPicoTokensLocked(h.configPath) + logger.InfoC("gateway", fmt.Sprintf("Attached to running gateway via PID file (PID: %d)", pid)) + } + gateway.mu.Unlock() + return } gateway.mu.Lock() @@ -319,6 +376,7 @@ func stopGatewayLocked() (int, error) { gateway.cmd = nil gateway.owned = false gateway.bootDefaultModel = "" + gateway.pidData = nil setGatewayRuntimeStatusLocked("stopped") return pid, nil @@ -371,6 +429,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int pid = existingPid gateway.cmd = nil // Clear first to ensure clean state if err = attachToGatewayProcessLocked(pid, cfg); err != nil { + logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to existing gateway (PID %d): %v", pid, err)) return 0, err } @@ -380,6 +439,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // Start new process // Locate the picoclaw executable execPath := utils.FindPicoclawBinary() + logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) cmd = exec.Command(execPath, "gateway", "-E") cmd.Env = os.Environ() @@ -407,10 +467,16 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - if _, err := h.EnsurePicoChannel(""); err != nil { + changed, err := h.EnsurePicoChannel("") + if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel } + // Refresh cached pico token in case EnsurePicoChannel generated a new one. + // Already holding gateway.mu from caller. + if changed { + refreshPicoTokensLocked(h.configPath) + } if err := cmd.Start(); err != nil { return 0, fmt.Errorf("failed to start gateway: %w", err) @@ -446,7 +512,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.mu.Unlock() }() - // Start a goroutine to probe health and update the runtime state once ready. + // Start a goroutine to probe pidFile and health, update runtime state once ready. go func() { for i := 0; i < 30; i++ { // try for up to 15 seconds time.Sleep(500 * time.Millisecond) @@ -456,13 +522,26 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int if !stillOurs { return } + + // Poll for pidFile first — once available we have port/host/token. + if pd := ppid.ReadPidFileWithCheck(globalConfigDir()); pd != nil && pd.PID == pid { + gateway.mu.Lock() + if gateway.cmd == cmd { + gateway.pidData = pd + setGatewayRuntimeStatusLocked("running") + } + gateway.mu.Unlock() + logger.InfoC("gateway", fmt.Sprintf("Gateway pidFile detected (PID: %d, port: %d)", pd.PID, pd.Port)) + return + } + + // Fallback: probe health endpoint to confirm liveness. cfg, err := config.LoadConfig(h.configPath) if err != nil { continue } - healthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second) - if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid { - // Verify the health endpoint returns the expected pid + _, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second) + if err == nil && statusCode == http.StatusOK { gateway.mu.Lock() if gateway.cmd == cmd { setGatewayRuntimeStatusLocked("running") @@ -480,49 +559,47 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // // POST /api/gateway/start func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { - // Prevent duplicate starts by checking health endpoint - cfg, cfgErr := config.LoadConfig(h.configPath) - if cfgErr == nil && cfg != nil { - healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err == nil && statusCode == http.StatusOK { - // Gateway is already running, attach to the existing process - pid := healthResp.Pid - gateway.mu.Lock() - ready, reason, err := h.gatewayStartReady() - if err != nil { - gateway.mu.Unlock() - http.Error( - w, - fmt.Sprintf("Failed to validate gateway start conditions: %v", err), - http.StatusInternalServerError, - ) - return - } - if !ready { - gateway.mu.Unlock() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]any{ - "status": "precondition_failed", - "message": reason, - }) - return - } - _, err = h.startGatewayLocked("starting", pid) + // Check PID file first to detect an already-running gateway. + pidData := ppid.ReadPidFileWithCheck(globalConfigDir()) + if pidData != nil { + pid := pidData.PID + gateway.mu.Lock() + ready, reason, err := h.gatewayStartReady() + if err != nil { + gateway.mu.Unlock() + http.Error( + w, + fmt.Sprintf("Failed to validate gateway start conditions: %v", err), + http.StatusInternalServerError, + ) + return + } + if !ready { gateway.mu.Unlock() - if err != nil { - logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) - http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError) - return - } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ - "status": "ok", - "pid": pid, + "status": "precondition_failed", + "message": reason, }) return } + _, err = h.startGatewayLocked("starting", pid) + if err != nil { + gateway.mu.Unlock() + logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err)) + http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError) + return + } + gateway.pidData = pidData + gateway.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) + return } gateway.mu.Lock() @@ -722,65 +799,56 @@ func (h *Handler) gatewayStatusData() map[string]any { } } - // Probe health endpoint to get pid and status - healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err != nil { + // Primary detection: read PID file and check if process is alive. + pidData := ppid.ReadPidFileWithCheck(globalConfigDir()) + if pidData != nil { gateway.mu.Lock() - data["gateway_status"] = gatewayStatusWithoutHealthLocked() + gateway.pidData = pidData + if pidData.Version != "" { + data["gateway_version"] = pidData.Version + } + setGatewayRuntimeStatusLocked("running") + + // Attach if we don't already track this PID. + if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != pidData.PID { + _ = attachToGatewayProcessLocked(pidData.PID, cfg) + } + + bootDefaultModel := gateway.bootDefaultModel + if bootDefaultModel != "" { + data["boot_default_model"] = bootDefaultModel + } + data["gateway_status"] = "running" + data["pid"] = pidData.PID gateway.mu.Unlock() - logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err)) } else { - if statusCode != http.StatusOK { - logger.WarnC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode)) + // Fallback: probe health endpoint to get pid and status + _, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) + if err != nil { gateway.mu.Lock() - setGatewayRuntimeStatusLocked("error") + data["gateway_status"] = gatewayStatusWithoutHealthLocked() + gateway.pidData = nil gateway.mu.Unlock() - data["gateway_status"] = "error" - data["status_code"] = statusCode + logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err)) } else { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("running") - if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid { - oldPid := "none" - if gateway.cmd != nil && gateway.cmd.Process != nil { - oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid) - } - logger.InfoC( - "gateway", - fmt.Sprintf( - "Detected new gateway PID (old: %s, new: %d), attempting to attach", - oldPid, - healthResp.Pid, - ), - ) - - if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil { - // Failed to find the process, treat as error - setGatewayRuntimeStatusLocked("error") - data["gateway_status"] = "error" - data["pid"] = healthResp.Pid - logger.ErrorC( - "gateway", - fmt.Sprintf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err), - ) - } else { - // Successfully attached, update response data - bootDefaultModel := gateway.bootDefaultModel - if bootDefaultModel != "" { - data["boot_default_model"] = bootDefaultModel - } - data["gateway_status"] = "running" - data["pid"] = healthResp.Pid + logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode)) + if statusCode != http.StatusOK { + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("error") + gateway.pidData = nil + gateway.mu.Unlock() + data["gateway_status"] = "error" + data["status_code"] = statusCode + } else { + gateway.mu.Lock() + setGatewayRuntimeStatusLocked("running") + bootDefaultModel := gateway.bootDefaultModel + if bootDefaultModel != "" { + data["boot_default_model"] = bootDefaultModel } + data["gateway_status"] = "running" + gateway.mu.Unlock() } - - bootDefaultModel := gateway.bootDefaultModel - if bootDefaultModel != "" { - data["boot_default_model"] = bootDefaultModel - } - data["gateway_status"] = "running" - data["pid"] = healthResp.Pid - gateway.mu.Unlock() } } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index a5ba2bad2..a891be3c1 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -469,9 +469,6 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { if got := body["gateway_status"]; got != "running" { t.Fatalf("gateway_status = %#v, want %q", got, "running") } - if got := body["pid"]; got != float64(cmd.Process.Pid) { - t.Fatalf("pid = %#v, want %d", got, cmd.Process.Pid) - } if got := body["gateway_restart_required"]; got != false { t.Fatalf("gateway_restart_required = %#v, want false", got) } diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index d345d980c..8bef33ac8 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -10,6 +10,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. @@ -26,20 +27,55 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // The gateway bind host and port are resolved from the latest configuration. -func (h *Handler) createWsProxy() *httputil.ReverseProxy { - wsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL()) - wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) +func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.ReverseProxy { + wsProxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + target := h.gatewayProxyURL() + r.SetURL(target) + r.Out.Header.Set(protocolKey, tokenPrefix+token) + }, + ModifyResponse: func(r *http.Response) error { + if prot := r.Header.Values(protocolKey); len(prot) > 0 { + r.Header.Del(protocolKey) + if origProtocol != "" { + r.Header.Set(protocolKey, origProtocol) + } + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("Failed to proxy WebSocket: %v", err) + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + }, } return wsProxy } // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. -// The reverse proxy forwards the incoming upgrade handshake as-is. +// It validates the client token before forwarding; rejects immediately on failure. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - proxy := h.createWsProxy() - proxy.ServeHTTP(w, r) + gateway.mu.Lock() + gatewayAvailable := gateway.pidData != nil + gateway.mu.Unlock() + + if !gatewayAvailable { + logger.Warnf("Gateway not available for WebSocket proxy") + http.Error(w, "Gateway not available", http.StatusServiceUnavailable) + return + } + prot := r.Header.Values(protocolKey) + if len(prot) > 0 { + origProtocol := prot[0] + newToken := picoComposedToken(prot[0]) + if newToken != "" { + h.createWsProxy(origProtocol, newToken).ServeHTTP(w, r) + return + } + } + + logger.Warnf("Invalid Pico token: %v", prot) + http.Error(w, "Invalid Pico token", http.StatusForbidden) } } @@ -81,6 +117,11 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } + // Refresh cached pico token. + gateway.mu.Lock() + gateway.picoToken = token + gateway.mu.Unlock() + wsURL := h.buildWsURL(r, cfg) w.Header().Set("Content-Type", "application/json") @@ -140,11 +181,15 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { return } + // Reload config (EnsurePicoChannel may have modified it) and refresh cache. cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } + if changed { + refreshPicoToken(cfg) + } wsURL := h.buildWsURL(r, cfg) @@ -162,7 +207,7 @@ func generateSecureToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // Fallback to something pseudo-random if crypto/rand fails - return fmt.Sprintf("pico_%x", time.Now().UnixNano()) + return fmt.Sprintf("%032x", time.Now().UnixNano()) } return hex.EncodeToString(b) } diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index aa377975d..beff4d77f 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/config" + ppid "github.com/sipeed/picoclaw/pkg/pid" ) func TestEnsurePicoChannel_FreshConfig(t *testing.T) { @@ -335,10 +336,22 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "pico" req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + req1.Header.Set(protocolKey, tokenPrefix+"wrong_token") rec1 := httptest.NewRecorder() handler(rec1, req1) + if rec1.Code != http.StatusForbidden { + t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusForbidden) + } + + req1 = httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + req1.Header.Set(protocolKey, tokenPrefix+"pico") + rec1 = httptest.NewRecorder() + handler(rec1, req1) + if rec1.Code != http.StatusOK { t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) } @@ -352,6 +365,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { } req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + req2.Header.Set(protocolKey, tokenPrefix+"pico") rec2 := httptest.NewRecorder() handler(rec2, req2) diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go index 3c2fb57dd..b2036f66c 100644 --- a/web/backend/api/skills.go +++ b/web/backend/api/skills.go @@ -309,14 +309,7 @@ func loadSkillContent(path string) (string, error) { } func globalConfigDir() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".picoclaw") + return config.GetHome() } func builtinSkillsDir() string { diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go index 5e0dfeb90..a0b7eb998 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -1,7 +1,9 @@ package middleware import ( + "bufio" "fmt" + "net" "net/http" "runtime/debug" "time" @@ -44,6 +46,15 @@ func (rr *responseRecorder) Unwrap() http.ResponseWriter { return rr.ResponseWriter } +// Hijack implements http.Hijacker so that WebSocket upgrades work through +// the middleware layer. +func (rr *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := rr.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, http.ErrNotSupported +} + // Logger logs each HTTP request with method, path, status code, and duration. func Logger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 772cd7ec0..0b9e30979 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -9,16 +9,13 @@ import ( "runtime" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { - if home := os.Getenv(config.EnvHome); home != "" { - return home - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw") + return config.GetHome() } // GetDefaultConfigPath returns the default path to the picoclaw config file. @@ -47,6 +44,7 @@ func FindPicoclawBinary() string { } if exe, err := os.Executable(); err == nil { + logger.Debugf("Trying to find picoclaw binary in %s", exe) candidate := filepath.Join(filepath.Dir(exe), binaryName) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate From 4f90909af34cbb9d67ca21e7eb20861c293eb7c7 Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Wed, 25 Mar 2026 07:02:23 +0100 Subject: [PATCH 15/72] feat(bedrock): detect SSO token expiration and provide actionable error When AWS SSO credentials expire, provide a clear error message instructing the user to run 'aws sso login' to refresh their session. --- pkg/providers/bedrock/provider_bedrock.go | 31 ++++++++++ .../bedrock/provider_bedrock_test.go | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go index 15c4f664e..9ca29455f 100644 --- a/pkg/providers/bedrock/provider_bedrock.go +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -206,6 +206,10 @@ func (p *Provider) Chat( // Call Bedrock Converse API output, err := p.client.Converse(ctx, input) if err != nil { + // Check for SSO token expiration errors and provide actionable guidance + if isSSOTokenError(err) { + return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err) + } return nil, fmt.Errorf("bedrock converse: %w", err) } @@ -580,3 +584,30 @@ func parseResponse(output *bedrockruntime.ConverseOutput) (*LLMResponse, error) Usage: usage, }, nil } + +// isSSOTokenError checks if the error is related to expired or invalid AWS SSO tokens. +// This helps provide actionable guidance when SSO credentials need to be refreshed. +// Only matches SSO-specific error patterns to avoid misclassifying other AWS credential errors. +func isSSOTokenError(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(err.Error()) + + // Check for specific SSO token expiration/refresh-related error patterns (case-insensitive) + // Avoid matching generic patterns that could match non-SSO AWS errors (e.g., STS ExpiredToken) + if strings.Contains(lower, "refresh cached sso token") { + return true + } + if strings.Contains(lower, "read cached sso token") { + return true + } + if strings.Contains(lower, "sso oidc") { + return true + } + if strings.Contains(lower, "invalidgrantexception") { + return true + } + + return false +} diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go index 754d112ee..882c2971c 100644 --- a/pkg/providers/bedrock/provider_bedrock_test.go +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -8,6 +8,7 @@ package bedrock import ( + "fmt" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -539,3 +540,64 @@ func TestParseResponse_ToolCallWithNilInput(t *testing.T) { assert.NotNil(t, resp.ToolCalls[0].Arguments) assert.Empty(t, resp.ToolCalls[0].Arguments) } + +func TestIsSSOTokenError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "generic error", + err: fmt.Errorf("connection refused"), + expected: false, + }, + { + name: "SSO config error not expiration", + err: fmt.Errorf("failed to load SSO profile: invalid SSO session"), + expected: false, + }, + { + name: "STS ExpiredToken error", + err: fmt.Errorf("ExpiredToken: The security token included in the request is expired"), + expected: false, + }, + { + name: "SSO token refresh error", + err: fmt.Errorf("refresh cached SSO token failed"), + expected: true, + }, + { + name: "InvalidGrantException", + err: fmt.Errorf("operation error SSO OIDC: CreateToken, InvalidGrantException"), + expected: true, + }, + { + name: "SSO OIDC error", + err: fmt.Errorf("operation error SSO OIDC: CreateToken, failed"), + expected: true, + }, + { + name: "full SSO error message", + err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"), + expected: true, + }, + { + name: "SSO token file missing", + err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSSOTokenError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} From 6ea364e67dead37e09ffc8d535ccc262a0ffe202 Mon Sep 17 00:00:00 2001 From: zeed zhao Date: Sun, 29 Mar 2026 13:11:43 +0800 Subject: [PATCH 16/72] feat(web): protect launcher dashboard with token and SPA login (#1953) Add token-based authentication for the Launcher's embedded Web Dashboard. - Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var) - HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS) - Bearer token support for API/script access - Rate limiting on login (10 attempts/IP/min) - Referrer-Policy: no-referrer on all responses - POST-only logout with JSON content-type (CSRF-safe) - System tray "Copy dashboard token" action - Login page shows contextual help (console/tray/log file path) - Path traversal protection via path.Clean - X-Forwarded-Host/Port/Proto support for reverse proxy deployments - Full i18n support (English, Chinese) Co-Authored-By: Claude Opus 4.6 --- docs/configuration.md | 12 + docs/docker.md | 2 +- docs/zh/configuration.md | 12 + docs/zh/docker.md | 4 +- go.mod | 1 + go.sum | 2 + pkg/config/security_integration_test.go | 2 +- web/backend/api/auth.go | 142 +++++++++++ web/backend/api/auth_login_limiter.go | 59 +++++ web/backend/api/auth_test.go | 218 +++++++++++++++++ web/backend/api/gateway_host.go | 118 ++++++++- web/backend/api/gateway_host_test.go | 62 ++++- web/backend/api/pico.go | 6 +- web/backend/app_runtime.go | 8 +- web/backend/i18n.go | 6 + web/backend/launcherconfig/config.go | 35 +++ web/backend/launcherconfig/config_test.go | 47 ++++ web/backend/main.go | 67 +++++- .../middleware/launcher_dashboard_auth.go | 226 ++++++++++++++++++ .../launcher_dashboard_auth_test.go | 162 +++++++++++++ web/backend/middleware/referrer_policy.go | 12 + web/backend/systray.go | 13 + web/backend/tray_offers_copy.go | 5 + web/backend/tray_offers_copy_stub.go | 5 + web/frontend/src/api/channels.ts | 4 +- web/frontend/src/api/gateway.ts | 4 +- web/frontend/src/api/http.ts | 42 ++++ web/frontend/src/api/launcher-auth.ts | 48 ++++ web/frontend/src/api/models.ts | 3 +- web/frontend/src/api/oauth.ts | 4 +- web/frontend/src/api/pico.ts | 4 +- web/frontend/src/api/sessions.ts | 8 +- web/frontend/src/api/skills.ts | 6 +- web/frontend/src/api/system.ts | 4 +- web/frontend/src/api/tools.ts | 4 +- .../src/components/config/config-page.tsx | 3 +- .../src/components/config/raw-config-page.tsx | 5 +- web/frontend/src/features/chat/websocket.ts | 12 + web/frontend/src/i18n/locales/en.json | 14 ++ web/frontend/src/i18n/locales/zh.json | 14 ++ web/frontend/src/lib/launcher-login-path.ts | 9 + web/frontend/src/routeTree.gen.ts | 21 ++ web/frontend/src/routes/__root.tsx | 43 +++- web/frontend/src/routes/launcher-login.tsx | 184 ++++++++++++++ 44 files changed, 1617 insertions(+), 45 deletions(-) create mode 100644 web/backend/api/auth.go create mode 100644 web/backend/api/auth_login_limiter.go create mode 100644 web/backend/api/auth_test.go create mode 100644 web/backend/middleware/launcher_dashboard_auth.go create mode 100644 web/backend/middleware/launcher_dashboard_auth_test.go create mode 100644 web/backend/middleware/referrer_policy.go create mode 100644 web/backend/tray_offers_copy.go create mode 100644 web/backend/tray_offers_copy_stub.go create mode 100644 web/frontend/src/api/http.ts create mode 100644 web/frontend/src/api/launcher-auth.ts create mode 100644 web/frontend/src/lib/launcher-login-path.ts create mode 100644 web/frontend/src/routes/launcher-login.tsx diff --git a/docs/configuration.md b/docs/configuration.md index 9360d3897..3462767e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,18 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa > **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request. +### Web launcher dashboard + +**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used). + +**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**. + +- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`. +- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header. +- **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern). +- **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded). +- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires. + ### Skill Sources By default, skills are loaded from: diff --git a/docs/docker.md b/docs/docker.md index a00dfbe9f..18de1ee83 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. > [!WARNING] -> The web console does not yet support authentication. Avoid exposing it to the public internet. +> The web console uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. ### Agent Mode (One-shot) diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 335566d36..3b0ac9a50 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -51,6 +51,18 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work > **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。 +### Web 启动器控制台 + +用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 + +**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。 + +- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 +- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。 +- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。 +- **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。 + ### 技能来源 (Skill Sources) 默认情况下,技能会按以下顺序加载: diff --git a/docs/zh/docker.md b/docs/zh/docker.md index 10bc46544..8aed1e86b 100644 --- a/docs/zh/docker.md +++ b/docs/zh/docker.md @@ -42,10 +42,10 @@ docker compose -f docker/docker-compose.yml --profile gateway down docker compose -f docker/docker-compose.yml --profile launcher up -d ``` -在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。 +在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 > [!WARNING] -> Web 控制台尚不支持身份验证。请勿将其暴露到公网。 +> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) diff --git a/go.mod b/go.mod index 54c275102..202839c29 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect diff --git a/go.sum b/go.sum index ae12473f3..c64f3593d 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 24170f84b..6ca8637f4 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -// Test JSON unmarshal of private fields +// Test JSON unmarshal of private fields (unexported fields are never filled, with or without json tag). func TestJSONUnmarshalPrivateFields(t *testing.T) { type testStruct struct { PublicField string `json:"public"` diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go new file mode 100644 index 000000000..b9b4d5f66 --- /dev/null +++ b/web/backend/api/auth.go @@ -0,0 +1,142 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +// LauncherAuthRouteOpts configures dashboard token login handlers. +type LauncherAuthRouteOpts struct { + DashboardToken string + SessionCookie string + SecureCookie func(*http.Request) bool + // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets). + TokenHelp LauncherAuthTokenHelp +} + +// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token. +type LauncherAuthTokenHelp struct { + EnvVarName string `json:"env_var_name"` + LogFileAbs string `json:"log_file,omitempty"` + TrayCopyMenu bool `json:"tray_copy_menu"` + ConsoleStdout bool `json:"console_stdout"` +} + +type launcherAuthLoginBody struct { + Token string `json:"token"` +} + +type launcherAuthStatusResponse struct { + Authenticated bool `json:"authenticated"` + TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"` +} + +// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status. +func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) { + secure := opts.SecureCookie + if secure == nil { + secure = middleware.DefaultLauncherDashboardSecureCookie + } + h := &launcherAuthHandlers{ + token: opts.DashboardToken, + sessionCookie: opts.SessionCookie, + secureCookie: secure, + tokenHelp: opts.TokenHelp, + loginLimit: newLoginRateLimiter(), + } + mux.HandleFunc("POST /api/auth/login", h.handleLogin) + mux.HandleFunc("POST /api/auth/logout", h.handleLogout) + mux.HandleFunc("GET /api/auth/status", h.handleStatus) +} + +type launcherAuthHandlers struct { + token string + sessionCookie string + secureCookie func(*http.Request) bool + tokenHelp LauncherAuthTokenHelp + loginLimit *loginRateLimiter +} + +func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var body launcherAuthLoginBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON"}`)) + return + } + ip := clientIPForLimiter(r) + if !h.loginLimit.allow(ip) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":"too many login attempts"}`)) + return + } + in := strings.TrimSpace(body.Token) + if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid token"}`)) + return + } + + middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"error":"method not allowed"}`)) + return + } + ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) + if !strings.HasPrefix(ct, "application/json") { + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = w.Write([]byte(`{"error":"Content-Type must be application/json"}`)) + return + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, logoutBodyMaxBytes)) + if err := dec.Decode(&struct{}{}); err != nil && err != io.EOF { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON body"}`)) + return + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON body"}`)) + return + } + + middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + ok := false + if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { + ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + } + if ok { + _, _ = w.Write([]byte(`{"authenticated":true}`)) + return + } + resp := launcherAuthStatusResponse{ + Authenticated: false, + TokenHelp: &h.tokenHelp, + } + enc, err := json.Marshal(resp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + return + } + _, _ = w.Write(enc) +} diff --git a/web/backend/api/auth_login_limiter.go b/web/backend/api/auth_login_limiter.go new file mode 100644 index 000000000..d606f03cf --- /dev/null +++ b/web/backend/api/auth_login_limiter.go @@ -0,0 +1,59 @@ +package api + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + loginAttemptsPerIP = 10 + loginAttemptWindow = time.Minute + logoutBodyMaxBytes = 4096 +) + +// loginRateLimiter limits POST /api/auth/login attempts per IP per minute. +type loginRateLimiter struct { + mu sync.Mutex + now func() time.Time + byIP map[string][]time.Time +} + +func newLoginRateLimiter() *loginRateLimiter { + return &loginRateLimiter{ + now: time.Now, + byIP: make(map[string][]time.Time), + } +} + +// allow reserves a slot for this request; false means rate limit exceeded. +func (l *loginRateLimiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + cutoff := now.Add(-loginAttemptWindow) + times := l.byIP[ip] + var kept []time.Time + for _, ts := range times { + if ts.After(cutoff) { + kept = append(kept, ts) + } + } + if len(kept) >= loginAttemptsPerIP { + l.byIP[ip] = kept + return false + } + kept = append(kept, now) + l.byIP[ip] = kept + return true +} + +func clientIPForLimiter(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go new file mode 100644 index 000000000..d2624a440 --- /dev/null +++ b/web/backend/api/auth_test.go @@ -0,0 +1,218 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/web/backend/middleware" +) + +func TestLauncherAuthLoginAndStatus(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = 0x55 + } + const tok = "dashboard-test-token-9" + sess := middleware.SessionCookieValue(key, tok) + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: tok, + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{ + EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", + LogFileAbs: "/tmp/launcher.log", + TrayCopyMenu: true, + ConsoleStdout: false, + }, + }) + + t.Run("status_unauthenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d", rec.Code) + } + var body struct { + Authenticated bool `json:"authenticated"` + TokenHelp *LauncherAuthTokenHelp `json:"token_help"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Authenticated || body.TokenHelp == nil { + t.Fatalf("unexpected body: %+v", body) + } + if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" { + t.Fatalf("token_help = %+v", body.TokenHelp) + } + }) + + t.Run("login_ok", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:12345" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != middleware.LauncherDashboardCookieName { + t.Fatalf("cookies = %#v", cookies) + } + }) + + t.Run("status_authenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil) + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d", rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"authenticated":true`)) { + t.Fatalf("body = %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "token_help") { + t.Fatalf("authenticated response should omit token_help: %s", rec.Body.String()) + } + }) +} + +func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "tok") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "tok", + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"}, + }) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/logout", nil)) + if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound { + t.Fatalf("GET logout: code = %d (expected 404 or 405)", rec.Code) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusUnsupportedMediaType { + t.Fatalf("wrong content-type: code = %d body=%s", rec2.Code, rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`)) + req3.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("POST json logout: code = %d", rec3.Code) + } +} + +func TestLauncherAuthLoginRateLimit(t *testing.T) { + key := make([]byte, 32) + const tok = "rate-limit-tok-xxxxxxxx" + sess := middleware.SessionCookieValue(key, tok) + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: tok, + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + }) + + // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. + wrongBody := `{"token":"wrong"}` + for i := 0; i < loginAttemptsPerIP; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.5.5:9999" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("iter %d: want 401 got %d %s", i, rec.Code, rec.Body.String()) + } + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "192.168.5.5:9999" + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("11th attempt: want 429 got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestLoginRateLimiterWindow(t *testing.T) { + l := newLoginRateLimiter() + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + l.now = func() time.Time { return t0 } + for i := 0; i < loginAttemptsPerIP; i++ { + if !l.allow("ip") { + t.Fatalf("want allow at %d", i) + } + } + if l.allow("ip") { + t.Fatal("want deny on 11th") + } + l.now = func() time.Time { return t0.Add(loginAttemptWindow + time.Second) } + if !l.allow("ip") { + t.Fatal("want allow after window") + } +} + +func TestReferrerPolicyMiddleware(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + h := middleware.ReferrerPolicyNoReferrer(next) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Fatalf("Referrer-Policy = %q", got) + } +} + +func TestLauncherAuthLogoutEmptyBody(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "tok") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "tok", + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req.Header.Set("Content-Type", "application/json") + req.Body = http.NoBody + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } +} + +func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "tok") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "tok", + SessionCookie: sess, + TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400 got %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 592571a28..6190f0c7c 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -93,16 +93,120 @@ func requestWSScheme(r *http.Request) string { return "ws" } -func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { - host := h.effectiveGatewayBindHost(cfg) - if host == "" || host == "0.0.0.0" { - host = requestHostName(r) +// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). +func requestHTTPScheme(r *http.Request) string { + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) + if proto == "https" || proto == "wss" { + return "https" + } + if proto == "http" || proto == "ws" { + return "http" + } } - // Use web server port instead of gateway port to avoid exposing extra ports - // The WebSocket connection will be proxied by the backend to the gateway + if r.TLS != nil { + return "https" + } + return "http" +} + +// forwardedHostFirst returns the client-visible host from reverse-proxy / tunnel headers +// (e.g. VS Code port forwarding, nginx). Empty if unset. +func forwardedHostFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if raw == "" { + raw = forwardedRFC7239Host(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return raw +} + +// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239). +func forwardedRFC7239Host(r *http.Request) string { + v := strings.TrimSpace(r.Header.Get("Forwarded")) + if v == "" { + return "" + } + first := strings.TrimSpace(strings.Split(v, ",")[0]) + for _, part := range strings.Split(first, ";") { + part = strings.TrimSpace(part) + low := strings.ToLower(part) + if !strings.HasPrefix(low, "host=") { + continue + } + val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) + if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' { + val = val[1 : len(val)-1] + } + return val + } + return "" +} + +// forwardedPortFirst returns the first X-Forwarded-Port value, or empty. +func forwardedPortFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Port")) + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return raw +} + +// clientVisiblePort picks the TCP port the browser uses to reach this app (after proxies). +// Used by picoWebUIAddr → buildWsURL / buildPicoEventsURL / buildPicoSendURL so WebSocket and +// HTTP URLs match the dashboard page origin (cookies / token flow behind tunnels and reverse proxies). +func clientVisiblePort(r *http.Request, serverListenPort int) string { + if p := forwardedPortFirst(r); p != "" { + return p + } + if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { + return port + } + if requestHTTPScheme(r) == "https" { + return "443" + } + return strconv.Itoa(serverListenPort) +} + +// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. +func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort int) string { + if h, p, err := net.SplitHostPort(host); err == nil { + return net.JoinHostPort(h, p) + } + return net.JoinHostPort(host, clientVisiblePort(r, serverListenPort)) +} + +// picoWebUIAddr is host:port for URLs returned to the browser (/pico/ws, /pico/events, /pico/send). +// It must match the HTTP Host the client used (or X-Forwarded-*), not cfg.Gateway.Host — otherwise +// e.g. page on localhost with ws_url 127.0.0.1 omits cookies and the dashboard auth handshake fails. +func (h *Handler) picoWebUIAddr(r *http.Request) string { wsPort := h.serverPort if wsPort == 0 { wsPort = 18800 // default web server port } - return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws" + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + return joinClientVisibleHostPort(r, fwdHost, wsPort) + } + host := requestHostName(r) + return net.JoinHostPort(host, strconv.Itoa(wsPort)) +} + +func (h *Handler) buildWsURL(r *http.Request) string { + return requestWSScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/ws" +} + +func (h *Handler) buildPicoEventsURL(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/events" +} + +func (h *Handler) buildPicoSendURL(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/send" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index ae3434862..7150b6fee 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -51,9 +51,16 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) req.Host = "192.168.1.9:18800" - if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" { + if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") } + + if got := h.buildPicoEventsURL(req); got != "http://192.168.1.9:18800/pico/events" { + t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/events") + } + if got := h.buildPicoSendURL(req); got != "http://192.168.1.9:18800/pico/send" { + t.Fatalf("buildPicoSendURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/send") + } } func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { @@ -147,7 +154,7 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" { + if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") } } @@ -164,11 +171,45 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" { + if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") } } +func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil) + req.Host = "127.0.0.1:18800" + req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Port", "443") + + if got := h.buildPicoEventsURL(req); got != "https://vscode-tunnel.example.com:443/pico/events" { + t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/events") + } + if got := h.buildPicoSendURL(req); got != "https://vscode-tunnel.example.com:443/pico/send" { + t.Fatalf("buildPicoSendURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/send") + } + if got := h.buildWsURL(req); got != "wss://vscode-tunnel.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://vscode-tunnel.example.com:443/pico/ws") + } +} + func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -182,7 +223,20 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" { + if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") } } + +func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil) + req.Host = "localhost:18800" + + if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws") + } +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index d345d980c..a3f1a4ffb 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := h.buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := h.buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ @@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { return } - wsURL := h.buildWsURL(r, cfg) + wsURL := h.buildWsURL(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go index e3a9ec64f..ab564db2c 100644 --- a/web/backend/app_runtime.go +++ b/web/backend/app_runtime.go @@ -55,8 +55,12 @@ func shutdownApp() { } func openBrowser() error { - if serverAddr == "" { + target := browserLaunchURL + if target == "" { + target = serverAddr + } + if target == "" { return fmt.Errorf("server address not set") } - return utils.OpenBrowser(serverAddr) + return utils.OpenBrowser(target) } diff --git a/web/backend/i18n.go b/web/backend/i18n.go index 9cda9e5d5..106df8506 100644 --- a/web/backend/i18n.go +++ b/web/backend/i18n.go @@ -24,6 +24,8 @@ const ( AppTooltip TranslationKey = "AppTooltip" MenuOpen TranslationKey = "MenuOpen" MenuOpenTooltip TranslationKey = "MenuOpenTooltip" + MenuCopyToken TranslationKey = "MenuCopyToken" + MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint" MenuAbout TranslationKey = "MenuAbout" MenuAboutTooltip TranslationKey = "MenuAboutTooltip" MenuVersion TranslationKey = "MenuVersion" @@ -47,6 +49,8 @@ var translations = map[Language]map[TranslationKey]string{ AppTooltip: "%s - Web Console", MenuOpen: "Open Console", MenuOpenTooltip: "Open PicoClaw console in browser", + MenuCopyToken: "Copy dashboard token", + MenuCopyTokenHint: "Copy the current web console access token to the clipboard", MenuAbout: "About", MenuAboutTooltip: "About PicoClaw", MenuVersion: "Version: %s", @@ -64,6 +68,8 @@ var translations = map[Language]map[TranslationKey]string{ AppTooltip: "%s - Web Console", MenuOpen: "打开控制台", MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", + MenuCopyToken: "复制控制台口令", + MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板", MenuAbout: "关于", MenuAboutTooltip: "关于 PicoClaw", MenuVersion: "版本: %s", diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index 4dca45b0e..b8465ef74 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,6 +1,8 @@ package launcherconfig import ( + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "net" @@ -14,6 +16,11 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 + + // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits). + dashboardSigningKeyBytes = 32 + // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits). + dashboardTokenEntropyBytes = 32 ) // Config stores launch parameters for the web backend service. @@ -41,6 +48,34 @@ func Validate(cfg Config) error { return nil } +// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this +// process. The signing key is freshly random each call; the token comes from the environment +// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token. +func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) { + signingKey = make([]byte, dashboardSigningKeyBytes) + if _, err = rand.Read(signingKey); err != nil { + return "", nil, false, err + } + + effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN")) + if effectiveToken != "" { + return effectiveToken, signingKey, false, nil + } + tok, genErr := randomDashboardToken() + if genErr != nil { + return "", nil, false, genErr + } + return tok, signingKey, true, nil +} + +func randomDashboardToken() (string, error) { + buf := make([]byte, dashboardTokenEntropyBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index c63bee09a..4e8a54e41 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { @@ -75,6 +77,51 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } +func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { + t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") + + tok, key, newTok, err := EnsureDashboardSecrets() + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes { + t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key)) + } + mac := middleware.SessionCookieValue(key, tok) + if mac == "" { + t.Fatal("empty session mac") + } + + tok2, key2, newTok2, err := EnsureDashboardSecrets() + if err != nil { + t.Fatalf("EnsureDashboardSecrets() second error = %v", err) + } + if !newTok2 { + t.Fatal("second call without env should generate another random token") + } + if tok2 == tok { + t.Fatal("expected a new random dashboard token") + } + if string(key2) == string(key) { + t.Fatal("expected a new signing key") + } +} + +func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { + t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") + + tok, _, newTok, err := EnsureDashboardSecrets() + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if tok != "env-only-token-override" { + t.Fatalf("token = %q, want env value", tok) + } + if newTok { + t.Fatal("newRandomDashboardToken should be false when env is set") + } +} + func TestNormalizeCIDRs(t *testing.T) { got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) want := []string{"192.168.1.0/24", "10.0.0.0/8"} diff --git a/web/backend/main.go b/web/backend/main.go index 6987a4515..c58e97361 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -16,6 +16,7 @@ import ( "flag" "fmt" "net/http" + "net/url" "os" "os/signal" "path/filepath" @@ -44,7 +45,12 @@ var ( server *http.Server serverAddr string - apiHandler *api.Handler + // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). + // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use. + browserLaunchURL string + apiHandler *api.Handler + // launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode). + launcherDashboardTokenForClipboard string noBrowser *bool ) @@ -57,7 +63,7 @@ func main() { console := flag.Bool("console", false, "Console mode, no GUI") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") + fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName) fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Arguments:\n") fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") @@ -98,8 +104,8 @@ func main() { defer logger.DisableFileLogging() } - logger.InfoC("web", fmt.Sprintf("%s Launcher %s starting...", appName, appVersion)) - logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome)) + logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion)) + logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome)) // Set language from command line or auto-detect if *lang != "" { @@ -118,7 +124,7 @@ func main() { } err = utils.EnsureOnboarded(absPath) if err != nil { - logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err) + logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err) } var explicitPort bool @@ -156,6 +162,13 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } + dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets() + if dashErr != nil { + logger.Fatalf("Dashboard auth setup failed: %v", dashErr) + } + dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) + launcherDashboardTokenForClipboard = dashboardToken + // Determine listen address var addr string if effectivePublic { @@ -167,6 +180,21 @@ func main() { // Initialize Server components mux := http.NewServeMux() + tokenLogFileAbs := "" + if !enableConsole { + tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile) + } + api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ + DashboardToken: dashboardToken, + SessionCookie: dashboardSessionCookie, + TokenHelp: api.LauncherAuthTokenHelp{ + EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", + LogFileAbs: tokenLogFileAbs, + TrayCopyMenu: trayOffersDashboardTokenCopy(), + ConsoleStdout: enableConsole, + }, + }) + // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) if _, err = apiHandler.EnsurePicoChannel(""); err != nil { @@ -183,14 +211,21 @@ func main() { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } + dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ + ExpectedCookie: dashboardSessionCookie, + Token: dashboardToken, + }, accessControlledMux) + // Apply middleware stack handler := middleware.Recoverer( middleware.Logger( - middleware.JSONContentType(accessControlledMux), + middleware.ReferrerPolicyNoReferrer( + middleware.JSONContentType(dashAuth), + ), ), ) - // Print startup banner (only in console mode) + // Print startup banner and token (console mode only). if enableConsole { fmt.Print(utils.Banner) fmt.Println() @@ -203,6 +238,19 @@ func main() { } } fmt.Println() + if newDashTok { + fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken) + } else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { + fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken) + } + fmt.Println() + } + + if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { + logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") + } + if !enableConsole && newDashTok { + logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) } // Log startup info to file @@ -215,6 +263,11 @@ func main() { // Share the local URL with the launcher runtime. serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort) + if dashboardToken != "" { + browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) + } else { + browserLaunchURL = serverAddr + } // Auto-open browser will be handled by the launcher runtime. diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go new file mode 100644 index 000000000..7e92fca22 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -0,0 +1,226 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "path" + "strings" + "time" +) + +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login. +const LauncherDashboardCookieName = "picoclaw_launcher_auth" + +// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). +const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 + +const launcherSessionMACLabel = "picoclaw-launcher-v1" + +// SessionCookieValue is the expected cookie value for the given signing key and dashboard token. +func SessionCookieValue(signingKey []byte, dashboardToken string) string { + mac := hmac.New(sha256.New, signingKey) + _, _ = mac.Write([]byte(launcherSessionMACLabel)) + _, _ = mac.Write([]byte{0}) + _, _ = mac.Write([]byte(dashboardToken)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// LauncherDashboardAuthConfig holds runtime material for dashboard access checks. +type LauncherDashboardAuthConfig struct { + ExpectedCookie string + Token string + // SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used. + SecureCookie func(*http.Request) bool +} + +// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto). +func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login. +func SetLauncherDashboardSessionCookie( + w http.ResponseWriter, + r *http.Request, + sessionValue string, + secure func(*http.Request) bool, +) { + if secure == nil { + secure = DefaultLauncherDashboardSecureCookie + } + http.SetCookie(w, &http.Cookie{ + Name: LauncherDashboardCookieName, + Value: sessionValue, + Path: "/", + MaxAge: launcherDashboardSessionMaxAgeSec, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure(r), + }) +} + +// ClearLauncherDashboardSessionCookie clears the dashboard session (e.g. logout). +func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, secure func(*http.Request) bool) { + if secure == nil { + secure = DefaultLauncherDashboardSecureCookie + } + http.SetCookie(w, &http.Cookie{ + Name: LauncherDashboardCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure(r), + Expires: time.Unix(0, 0), + }) +} + +// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer +// before calling next. Public paths are login page and /api/auth/* handlers. +func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := canonicalAuthPath(r.URL.Path) + if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled { + return + } + if isPublicLauncherDashboardPath(r.Method, p) { + next.ServeHTTP(w, r) + return + } + if validLauncherDashboardAuth(r, cfg) { + next.ServeHTTP(w, r) + return + } + rejectLauncherDashboardAuth(w, r, p) + }) +} + +// canonicalAuthPath matches path cleaning used for routing decisions so +// prefixes like /assets/../ cannot bypass auth (CVE-class traversal). + +// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session +// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects. +// Invalid token is rejected like any other unauthenticated browser request. +func tryLauncherQueryTokenLogin( + w http.ResponseWriter, + r *http.Request, + canonicalPath string, + cfg LauncherDashboardAuthConfig, +) bool { + if r.Method != http.MethodGet { + return false + } + if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { + return false + } + qToken := strings.TrimSpace(r.URL.Query().Get("token")) + if qToken == "" { + return false + } + if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { + rejectLauncherDashboardAuth(w, r, canonicalPath) + return true + } + SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) + http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther) + return true +} + +func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { + if canonicalPath == "/launcher-login" { + return "/" + } + q := r.URL.Query() + q.Del("token") + enc := q.Encode() + if enc != "" { + return canonicalPath + "?" + enc + } + return canonicalPath +} + +func canonicalAuthPath(raw string) string { + if raw == "" { + return "/" + } + c := path.Clean(raw) + switch c { + case ".", "": + return "/" + default: + if c[0] != '/' { + return "/" + c + } + return c + } +} + +func isPublicLauncherDashboardPath(method, p string) bool { + if isPublicLauncherDashboardStatic(method, p) { + return true + } + switch p { + case "/api/auth/login": + return method == http.MethodPost + case "/api/auth/logout": + return method == http.MethodPost + case "/api/auth/status": + return method == http.MethodGet + } + return false +} + +// isPublicLauncherDashboardStatic allows the SPA login route and embedded +// frontend assets without a session (GET/HEAD only). +func isPublicLauncherDashboardStatic(method, p string) bool { + if method != http.MethodGet && method != http.MethodHead { + return false + } + if p == "/launcher-login" { + return true + } + if strings.HasPrefix(p, "/assets/") { + return true + } + switch p { + case "/favicon.ico", "/favicon.svg", "/favicon-96x96.png", + "/apple-touch-icon.png", "/site.webmanifest", "/robots.txt": + return true + default: + return false + } +} + +func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig) bool { + if c, err := r.Cookie(LauncherDashboardCookieName); err == nil { + if subtle.ConstantTimeCompare([]byte(c.Value), []byte(cfg.ExpectedCookie)) == 1 { + return true + } + } + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if strings.HasPrefix(auth, prefix) { + token := strings.TrimSpace(auth[len(prefix):]) + if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 { + return true + } + } + return false +} + +func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) { + if strings.HasPrefix(canonicalPath, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + http.Redirect(w, r, "/launcher-login", http.StatusFound) +} diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go new file mode 100644 index 000000000..1b919bf96 --- /dev/null +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -0,0 +1,162 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSessionCookieValue_Deterministic(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + a := SessionCookieValue(key, "tok-a") + b := SessionCookieValue(key, "tok-a") + if a != b || a == "" { + t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b) + } + c := SessionCookieValue(key, "tok-b") + if c == a { + t.Fatal("SessionCookieValue should differ for different tokens") + } +} + +func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := LauncherDashboardAuth(cfg, next) + + for _, tc := range []struct { + method, path string + want int + }{ + {http.MethodGet, "/launcher-login", http.StatusTeapot}, + {http.MethodGet, "/assets/index.js", http.StatusTeapot}, + {http.MethodPost, "/api/auth/login", http.StatusTeapot}, + {http.MethodGet, "/api/auth/status", http.StatusTeapot}, + {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, + {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, + {http.MethodGet, "/api/config", http.StatusUnauthorized}, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, nil) + h.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, rec.Code, tc.want) + } + } +} + +func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { + const tok = "secret" + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if got := rec.Header().Get("Location"); got != "/" { + t.Fatalf("Location = %q, want %q", got, "/") + } + if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName { + t.Fatalf("expected one session cookie, got %#v", c) + } + + rec1b := httptest.NewRecorder() + req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil) + h.ServeHTTP(rec1b, req1b) + if rec1b.Code != http.StatusSeeOther { + t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code) + } + if got := rec1b.Header().Get("Location"); got != "/config?keep=1" { + t.Fatalf("Location = %q, want /config?keep=1", got) + } + + recBad := httptest.NewRecorder() + reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil) + h.ServeHTTP(recBad, reqBad) + if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" { + t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location")) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil) + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil) + h.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusFound { + t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code) + } + + recLogin := httptest.NewRecorder() + reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil) + h.ServeHTTP(recLogin, reqLogin) + if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" { + t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + for _, p := range []string{ + "/assets/../api/config", + "/launcher-login/../api/config", + "/./api/config", + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, p, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%q: status = %d, want %d", p, rec.Code, http.StatusUnauthorized) + } + } +} + +func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = 0xab + } + token := "dashboard-secret-9" + cookieVal := SessionCookieValue(key, token) + cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie auth: status = %d", rec.Code) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("Authorization", "Bearer "+token) + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("bearer auth: status = %d", rec2.Code) + } +} diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go new file mode 100644 index 000000000..5ac066614 --- /dev/null +++ b/web/backend/middleware/referrer_policy.go @@ -0,0 +1,12 @@ +package middleware + +import "net/http" + +// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive +// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header. +func ReferrerPolicyNoReferrer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} diff --git a/web/backend/systray.go b/web/backend/systray.go index 9dcc025df..744ea4611 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -6,6 +6,7 @@ import ( "fmt" "fyne.io/systray" + "github.com/atotto/clipboard" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/utils" @@ -23,6 +24,7 @@ func onReady() { // Create menu items mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) + mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint)) mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) // Add version info under About menu @@ -50,6 +52,17 @@ func onReady() { logger.Errorf("Failed to open browser: %v", err) } + case <-mCopyTok.ClickedCh: + if launcherDashboardTokenForClipboard == "" { + logger.WarnC("web", "Dashboard token is empty; cannot copy") + continue + } + if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil { + logger.Errorf("Failed to copy dashboard token: %v", err) + } else { + logger.InfoC("web", "Dashboard token copied to clipboard") + } + case <-mVersion.ClickedCh: // Version info - do nothing, just shows current version diff --git a/web/backend/tray_offers_copy.go b/web/backend/tray_offers_copy.go new file mode 100644 index 000000000..6b7d17412 --- /dev/null +++ b/web/backend/tray_offers_copy.go @@ -0,0 +1,5 @@ +//go:build (!darwin && !freebsd) || cgo + +package main + +func trayOffersDashboardTokenCopy() bool { return true } diff --git a/web/backend/tray_offers_copy_stub.go b/web/backend/tray_offers_copy_stub.go new file mode 100644 index 000000000..9312700f3 --- /dev/null +++ b/web/backend/tray_offers_copy_stub.go @@ -0,0 +1,5 @@ +//go:build (darwin || freebsd) && !cgo + +package main + +func trayOffersDashboardTokenCopy() bool { return false } diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts index 85550ca81..eb4d41fd7 100644 --- a/web/frontend/src/api/channels.ts +++ b/web/frontend/src/api/channels.ts @@ -1,5 +1,7 @@ // API client for channels navigation and channel-specific config flows. +import { launcherFetch } from "@/api/http" + export type ChannelConfig = Record export type AppConfig = Record @@ -22,7 +24,7 @@ interface ConfigActionResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts index 9e02a02b5..2742a0a37 100644 --- a/web/frontend/src/api/gateway.ts +++ b/web/frontend/src/api/gateway.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + // API client for gateway process management. interface GatewayStatusResponse { @@ -27,7 +29,7 @@ interface GatewayActionResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } diff --git a/web/frontend/src/api/http.ts b/web/frontend/src/api/http.ts new file mode 100644 index 000000000..0eb872f3f --- /dev/null +++ b/web/frontend/src/api/http.ts @@ -0,0 +1,42 @@ +import { isLauncherLoginPathname } from "@/lib/launcher-login-path" + +function isLauncherLoginPath(): boolean { + if (typeof globalThis.location === "undefined") { + return false + } + if (isLauncherLoginPathname(globalThis.location.pathname || "/")) { + return true + } + try { + return isLauncherLoginPathname( + new URL(globalThis.location.href).pathname || "/", + ) + } catch { + return false + } +} + +/** + * Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses. + * Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll). + */ +export async function launcherFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const res = await fetch(input, { + credentials: "same-origin", + ...init, + }) + if (res.status === 401) { + const ct = res.headers.get("content-type") || "" + if ( + ct.includes("application/json") && + typeof globalThis.location !== "undefined" && + !isLauncherLoginPath() + ) { + globalThis.location.assign("/launcher-login") + } + } + return res +} diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts new file mode 100644 index 000000000..247d5ab9e --- /dev/null +++ b/web/frontend/src/api/launcher-auth.ts @@ -0,0 +1,48 @@ +/** + * Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid + * redirect loops on 401 while on the login page. + */ +export async function postLauncherDashboardLogin( + token: string, +): Promise { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ token: token.trim() }), + }) + return res.ok +} + +export type LauncherAuthTokenHelp = { + env_var_name: string + log_file?: string + tray_copy_menu: boolean + console_stdout: boolean +} + +export type LauncherAuthStatus = { + authenticated: boolean + token_help?: LauncherAuthTokenHelp +} + +export async function getLauncherAuthStatus(): Promise { + const res = await fetch("/api/auth/status", { + method: "GET", + credentials: "same-origin", + }) + if (!res.ok) { + throw new Error(`status ${res.status}`) + } + return (await res.json()) as LauncherAuthStatus +} + +export async function postLauncherDashboardLogout(): Promise { + const res = await fetch("/api/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: "{}", + }) + return res.ok +} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index aa66a7389..d75b3ec3c 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -1,3 +1,4 @@ +import { launcherFetch } from "@/api/http" import { refreshGatewayState } from "@/store/gateway" // API client for model list management. @@ -39,7 +40,7 @@ interface ModelActionResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } diff --git a/web/frontend/src/api/oauth.ts b/web/frontend/src/api/oauth.ts index a1ed1afcb..689a2bcd1 100644 --- a/web/frontend/src/api/oauth.ts +++ b/web/frontend/src/api/oauth.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export type OAuthProvider = "openai" | "anthropic" | "google-antigravity" export type OAuthMethod = "browser" | "device_code" | "token" @@ -51,7 +53,7 @@ interface OAuthProvidersResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { const message = await res.text() throw new Error(message || `API error: ${res.status} ${res.statusText}`) diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts index 9a1a553d5..6b8ceb49a 100644 --- a/web/frontend/src/api/pico.ts +++ b/web/frontend/src/api/pico.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + // API client for Pico Channel configuration. interface PicoTokenResponse { @@ -16,7 +18,7 @@ interface PicoSetupResponse { const BASE_URL = "" async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, options) + const res = await launcherFetch(`${BASE_URL}${path}`, options) if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`) } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index 10b0d28fd..c91495901 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -1,5 +1,7 @@ // Sessions API — list and retrieve chat session history +import { launcherFetch } from "@/api/http" + export interface SessionSummary { id: string title: string @@ -26,7 +28,7 @@ export async function getSessions( limit: limit.toString(), }) - const res = await fetch(`/api/sessions?${params.toString()}`) + const res = await launcherFetch(`/api/sessions?${params.toString()}`) if (!res.ok) { throw new Error(`Failed to fetch sessions: ${res.status}`) } @@ -34,7 +36,7 @@ export async function getSessions( } export async function getSessionHistory(id: string): Promise { - const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`) + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`) if (!res.ok) { throw new Error(`Failed to fetch session ${id}: ${res.status}`) } @@ -42,7 +44,7 @@ export async function getSessionHistory(id: string): Promise { } export async function deleteSession(id: string): Promise { - const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { + const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }) if (!res.ok) { diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts index 307cbd788..72ccbcfe5 100644 --- a/web/frontend/src/api/skills.ts +++ b/web/frontend/src/api/skills.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export interface SkillSupportItem { name: string path: string @@ -22,7 +24,7 @@ interface SkillActionResponse { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(path, options) + const res = await launcherFetch(path, options) if (!res.ok) { throw new Error(await extractErrorMessage(res)) } @@ -41,7 +43,7 @@ export async function importSkill(file: File): Promise { const formData = new FormData() formData.set("file", file) - const res = await fetch("/api/skills/import", { + const res = await launcherFetch("/api/skills/import", { method: "POST", body: formData, }) diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 543c8694d..2e2f36f15 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export interface AutoStartStatus { enabled: boolean supported: boolean @@ -12,7 +14,7 @@ export interface LauncherConfig { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(path, options) + const res = await launcherFetch(path, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts index 9f09efbfd..824bcc0fa 100644 --- a/web/frontend/src/api/tools.ts +++ b/web/frontend/src/api/tools.ts @@ -1,3 +1,5 @@ +import { launcherFetch } from "@/api/http" + export interface ToolSupportItem { name: string description: string @@ -16,7 +18,7 @@ interface ToolActionResponse { } async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(path, options) + const res = await launcherFetch(path, options) if (!res.ok) { let message = `API error: ${res.status} ${res.statusText}` try { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 664e75440..cbe4d8e91 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" +import { launcherFetch } from "@/api/http" import { getAutoStartStatus, getLauncherConfig, @@ -50,7 +51,7 @@ export function ConfigPage() { const { data, isLoading, error } = useQuery({ queryKey: ["config"], queryFn: async () => { - const res = await fetch("/api/config") + const res = await launcherFetch("/api/config") if (!res.ok) { throw new Error("Failed to load config") } diff --git a/web/frontend/src/components/config/raw-config-page.tsx b/web/frontend/src/components/config/raw-config-page.tsx index 56a922fe6..f8f987651 100644 --- a/web/frontend/src/components/config/raw-config-page.tsx +++ b/web/frontend/src/components/config/raw-config-page.tsx @@ -5,6 +5,7 @@ import { useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { launcherFetch } from "@/api/http" import { PageHeader } from "@/components/page-header" import { AlertDialog, @@ -28,7 +29,7 @@ export function RawConfigPage() { const { data: config, isLoading } = useQuery({ queryKey: ["config"], queryFn: async () => { - const res = await fetch("/api/config") + const res = await launcherFetch("/api/config") if (!res.ok) { throw new Error("Failed to fetch config") } @@ -38,7 +39,7 @@ export function RawConfigPage() { const mutation = useMutation({ mutationFn: async (newConfig: string) => { - const res = await fetch("/api/config", { + const res = await launcherFetch("/api/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: newConfig, diff --git a/web/frontend/src/features/chat/websocket.ts b/web/frontend/src/features/chat/websocket.ts index 6b132e9a6..17ba36075 100644 --- a/web/frontend/src/features/chat/websocket.ts +++ b/web/frontend/src/features/chat/websocket.ts @@ -14,6 +14,18 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string { if (isLocalHost && !isBrowserLocal) { parsedUrl.hostname = window.location.hostname finalWsUrl = parsedUrl.toString() + } else if ( + isLocalHost && + isBrowserLocal && + parsedUrl.hostname !== window.location.hostname && + (parsedUrl.hostname === "127.0.0.1" || + parsedUrl.hostname === "localhost") && + (window.location.hostname === "127.0.0.1" || + window.location.hostname === "localhost") + ) { + // Same machine, but cookies are host-specific; match the page origin. + parsedUrl.hostname = window.location.hostname + finalWsUrl = parsedUrl.toString() } } catch (error) { console.warn("Could not parse ws_url:", error) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 25608fe93..38cdeb324 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -14,6 +14,20 @@ "config": "Config", "logs": "Logs" }, + "launcherLogin": { + "title": "Launcher access", + "description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable).", + "tokenLabel": "Token", + "tokenPlaceholder": "Enter access token", + "submit": "Continue to Dashboard", + "errorInvalid": "Invalid token. Please try again.", + "errorNetwork": "Network error. Please try again.", + "helpTitle": "Where to find the token", + "helpConsole": "Console mode: printed in the terminal when the launcher starts.", + "helpTray": "Tray mode: menu «Copy dashboard token».", + "helpLogFile": "Log file (startup line includes the token): {{path}}", + "helpEnv": "Stable token: set {{env}}." + }, "chat": { "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 346822407..9ec4ec967 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -14,6 +14,20 @@ "config": "配置", "logs": "日志" }, + "launcherLogin": { + "title": "Launcher 访问验证", + "description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)。", + "tokenLabel": "令牌", + "tokenPlaceholder": "输入访问令牌", + "submit": "进入 Dashboard", + "errorInvalid": "令牌错误,请重试。", + "errorNetwork": "网络错误,请重试。", + "helpTitle": "口令在哪里", + "helpConsole": "控制台模式:启动时在终端输出。", + "helpTray": "托盘模式:菜单「复制控制台口令」。", + "helpLogFile": "日志文件(启动时会写入口令):{{path}}", + "helpEnv": "固定口令:设置环境变量 {{env}}。" + }, "chat": { "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", diff --git a/web/frontend/src/lib/launcher-login-path.ts b/web/frontend/src/lib/launcher-login-path.ts new file mode 100644 index 000000000..52c35d240 --- /dev/null +++ b/web/frontend/src/lib/launcher-login-path.ts @@ -0,0 +1,9 @@ +/** Normalize URL pathname for comparisons (trailing slashes, empty). */ +export function normalizePathname(p: string): string { + const t = p.replace(/\/+$/, "") + return t === "" ? "/" : t +} + +export function isLauncherLoginPathname(pathname: string): boolean { + return normalizePathname(pathname) === "/launcher-login" +} diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 60f19ab53..536ee560b 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ModelsRouteImport } from './routes/models' import { Route as LogsRouteImport } from './routes/logs' +import { Route as LauncherLoginRouteImport } from './routes/launcher-login' import { Route as CredentialsRouteImport } from './routes/credentials' import { Route as ConfigRouteImport } from './routes/config' import { Route as AgentRouteImport } from './routes/agent' @@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({ path: '/logs', getParentRoute: () => rootRouteImport, } as any) +const LauncherLoginRoute = LauncherLoginRouteImport.update({ + id: '/launcher-login', + path: '/launcher-login', + getParentRoute: () => rootRouteImport, +} as any) const CredentialsRoute = CredentialsRouteImport.update({ id: '/credentials', path: '/credentials', @@ -83,6 +89,7 @@ export interface FileRoutesByFullPath { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -96,6 +103,7 @@ export interface FileRoutesByTo { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -110,6 +118,7 @@ export interface FileRoutesById { '/agent': typeof AgentRouteWithChildren '/config': typeof ConfigRouteWithChildren '/credentials': typeof CredentialsRoute + '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute @@ -125,6 +134,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/launcher-login' | '/logs' | '/models' | '/agent/skills' @@ -138,6 +148,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/launcher-login' | '/logs' | '/models' | '/agent/skills' @@ -151,6 +162,7 @@ export interface FileRouteTypes { | '/agent' | '/config' | '/credentials' + | '/launcher-login' | '/logs' | '/models' | '/agent/skills' @@ -165,6 +177,7 @@ export interface RootRouteChildren { AgentRoute: typeof AgentRouteWithChildren ConfigRoute: typeof ConfigRouteWithChildren CredentialsRoute: typeof CredentialsRoute + LauncherLoginRoute: typeof LauncherLoginRoute LogsRoute: typeof LogsRoute ModelsRoute: typeof ModelsRoute } @@ -185,6 +198,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LogsRouteImport parentRoute: typeof rootRouteImport } + '/launcher-login': { + id: '/launcher-login' + path: '/launcher-login' + fullPath: '/launcher-login' + preLoaderRoute: typeof LauncherLoginRouteImport + parentRoute: typeof rootRouteImport + } '/credentials': { id: '/credentials' path: '/credentials' @@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = { AgentRoute: AgentRouteWithChildren, ConfigRoute: ConfigRouteWithChildren, CredentialsRoute: CredentialsRoute, + LauncherLoginRoute: LauncherLoginRoute, LogsRoute: LogsRoute, ModelsRoute: ModelsRoute, } diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index 31fdb7804..d2303a29c 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -1,19 +1,56 @@ -import { Outlet, createRootRoute } from "@tanstack/react-router" +import { + Outlet, + createRootRoute, + useRouterState, +} from "@tanstack/react-router" import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { useEffect } from "react" import { AppLayout } from "@/components/app-layout" import { initializeChatStore } from "@/features/chat/controller" +import { isLauncherLoginPathname } from "@/lib/launcher-login-path" const RootLayout = () => { + // Prefer the real address bar path: stale embedded bundles may not register + // /launcher-login in the route tree, which would otherwise keep AppLayout + + // gateway polling → 401 → launcherFetch redirect loop. + const routerState = useRouterState({ + select: (s) => ({ + pathname: s.location.pathname, + matches: s.matches, + }), + }) + + const windowPath = + typeof globalThis.location !== "undefined" + ? globalThis.location.pathname || "/" + : routerState.pathname + + const isLauncherLogin = + isLauncherLoginPathname(windowPath) || + isLauncherLoginPathname(routerState.pathname) || + routerState.matches.some((m) => m.routeId === "/launcher-login") + useEffect(() => { + if (isLauncherLogin) { + return + } initializeChatStore() - }, []) + }, [isLauncherLogin]) + + if (isLauncherLogin) { + return ( + <> + + {import.meta.env.DEV ? : null} + + ) + } return ( - + {import.meta.env.DEV ? : null} ) } diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx new file mode 100644 index 000000000..e7f774df7 --- /dev/null +++ b/web/frontend/src/routes/launcher-login.tsx @@ -0,0 +1,184 @@ +import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react" +import { createFileRoute } from "@tanstack/react-router" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { + getLauncherAuthStatus, + postLauncherDashboardLogin, + type LauncherAuthTokenHelp, +} from "@/api/launcher-auth" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useTheme } from "@/hooks/use-theme" + +function LauncherLoginPage() { + const { t, i18n } = useTranslation() + const { theme, toggleTheme } = useTheme() + const [token, setToken] = React.useState("") + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState("") + const [tokenHelp, setTokenHelp] = React.useState( + null, + ) + + React.useEffect(() => { + let cancelled = false + void getLauncherAuthStatus() + .then((s) => { + if (cancelled || s.authenticated || !s.token_help) { + return + } + setTokenHelp(s.token_help) + }) + .catch(() => { + /* ignore; login form still usable */ + }) + return () => { + cancelled = true + } + }, []) + + const loginWithToken = React.useCallback( + async (tokenValue: string) => { + setError("") + setSubmitting(true) + try { + const ok = await postLauncherDashboardLogin(tokenValue) + if (ok) { + globalThis.location.assign("/") + return + } + setError(t("launcherLogin.errorInvalid")) + } catch { + setError(t("launcherLogin.errorNetwork")) + } finally { + setSubmitting(false) + } + }, + [t], + ) + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + await loginWithToken(token) + } + + return ( +
+
+ + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("zh")}> + 简体中文 + + + + +
+ +
+ + + {t("launcherLogin.title")} + {t("launcherLogin.description")} + + +
+
+ + setToken(e.target.value)} + placeholder={t("launcherLogin.tokenPlaceholder")} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ {tokenHelp ? ( +
+

+ {t("launcherLogin.helpTitle")} +

+
    + {tokenHelp.console_stdout ? ( +
  • {t("launcherLogin.helpConsole")}
  • + ) : null} + {tokenHelp.tray_copy_menu ? ( +
  • {t("launcherLogin.helpTray")}
  • + ) : null} + {tokenHelp.log_file ? ( +
  • + {t("launcherLogin.helpLogFile", { + path: tokenHelp.log_file, + })} +
  • + ) : null} + {tokenHelp.env_var_name ? ( +
  • + {t("launcherLogin.helpEnv", { + env: tokenHelp.env_var_name, + })} +
  • + ) : null} +
+
+ ) : null} +
+
+
+
+ ) +} + +export const Route = createFileRoute("/launcher-login")({ + component: LauncherLoginPage, +}) From e414b82ac3b160ad5efdca83cae9337767cbe8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E9=9D=92=E5=B7=9D?= <46062972+ShenQingchuan@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:47:28 +0800 Subject: [PATCH 17/72] fix(cron): publish agent response to outbound bus for cron-triggered jobs (#2100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cron): publish agent response to outbound bus for cron-triggered jobs When a cron job triggers agent execution via ProcessDirectWithChannel, the agent response was silently discarded — the code assumed AgentLoop would auto-publish it, but SendResponse is false on this path. Delegate to PublishResponseIfNeeded (exported from AgentLoop) so the response reaches the originating channel (e.g. Telegram) only when the message tool did not already deliver content in the same round. Also adds a "directive" message type to CronPayload, allowing cron jobs to instruct the agent to execute a task rather than echo static text. * fix(cron): add type validation and directive test coverage Address reviewer blocking feedback: 1. Server-side whitelist for `type` parameter — the `enum` in Parameters() is only an LLM schema hint; any string was persisted. Now `addJob` rejects values other than "message" and "directive". 2. Comprehensive test coverage for the directive code path: - directive adds prompt prefix to ProcessDirectWithChannel - deliver=true + directive routes through agent (not direct publish) - directive prompt content, sessionKey, channel, chatID are correct - invalid type is rejected; valid types ("", "message", "directive") pass - deliver=true message type goes directly to bus (regression) - agent error path does not trigger publish (regression) Also merge the two UpdateJob calls in addJob into one to avoid redundant disk I/O (non-blocking suggestion from review). * fix(cron): remove omitempty from CronPayload.Type for consistent JSON Empty string and "message" are semantically equivalent defaults; always serializing the field avoids asymmetric JSON output. * test(cron): remove redundant test, strengthen error path coverage - Remove ExecuteJobDirectivePassesCorrectContent: its assertions on sessionKey/channel/chatID duplicate ExecuteJobPublishesAgentResponse; its prompt check duplicates DirectiveAddsPromptPrefix. - Strengthen DirectiveAddsPromptPrefix with exact prompt match and publish response assertion. - Fix ReturnsErrorWithoutPublish: set non-empty stub response so the test verifies the error branch early-return, not the response=="" guard. * fix(ci): satisfy golines and gosmopolitan in cron code --- pkg/agent/loop.go | 6 +- pkg/cron/service.go | 1 + pkg/tools/cron.go | 54 +++++++-- pkg/tools/cron_test.go | 261 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 309 insertions(+), 13 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index db476c212..ef2951365 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -461,7 +461,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { if target == nil { cancelDrain() if finalResponse != "" { - al.publishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) + al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) } return } @@ -521,7 +521,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if finalResponse != "" { - al.publishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) } }() } @@ -603,7 +603,7 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } -func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { +func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { if response == "" { return } diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 77a413133..c1a224013 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -25,6 +25,7 @@ type CronSchedule struct { type CronPayload struct { Kind string `json:"kind"` + Type string `json:"type"` Message string `json:"message"` Command string `json:"command,omitempty"` Deliver bool `json:"deliver"` diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 154ec75f0..60d9d5e5a 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -16,6 +16,9 @@ import ( // JobExecutor is the interface for executing cron jobs through the agent type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) + // PublishResponseIfNeeded sends response to the outbound bus only when the + // agent did not already deliver content through the message tool in this round. + PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) } // CronTool provides scheduling capabilities for the agent @@ -111,6 +114,11 @@ func (t *CronTool) Parameters() map[string]any { "type": "string", "description": "Job ID (for remove/enable/disable)", }, + "type": map[string]any{ + "type": "string", + "enum": []string{"message", "directive"}, + "description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.", + }, "deliver": map[string]any{ "type": "boolean", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false", @@ -197,6 +205,12 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult deliver = d } + // Validate type parameter (server-side whitelist, not just LLM schema hint) + msgType, _ := args["type"].(string) + if msgType != "" && msgType != "message" && msgType != "directive" { + return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType)) + } + // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When // allow_command is disabled, explicit confirmation is required as an override. // Non-command reminders remain open to all channels. @@ -230,9 +244,17 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(fmt.Sprintf("Error adding job: %v", err)) } + // Apply optional payload fields and persist in a single UpdateJob call + needsUpdate := false if command != "" { job.Payload.Command = command - // Need to save the updated payload + needsUpdate = true + } + if msgType != "" { + job.Payload.Type = msgType + needsUpdate = true + } + if needsUpdate { t.cronService.UpdateJob(job) } @@ -347,8 +369,13 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return "ok" } - // If deliver=true, send message directly without agent processing - if job.Payload.Deliver { + // Determine message generation strategy + // Type="directive": treat message as instructions for AI agent to execute + // Type="" or "message" (default): static message content + isDirective := job.Payload.Type == "directive" + + // If deliver=true and not directive, send message directly without agent processing + if job.Payload.Deliver && !isDirective { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ @@ -359,13 +386,23 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return "ok" } - // For deliver=false, process through agent (for complex tasks) + // For deliver=false OR directive mode, process through agent sessionKey := fmt.Sprintf("cron-%s", job.ID) - // Call agent with job's message + // Prepare the prompt based on type + prompt := job.Payload.Message + if isDirective { + // For directive type, prefix to clarify this is an instruction + prompt = fmt.Sprintf( + "Please execute the following directive and provide the result:\n\n%s", + job.Payload.Message, + ) + } + + // Call agent with the prepared prompt response, err := t.executor.ProcessDirectWithChannel( ctx, - job.Payload.Message, + prompt, sessionKey, channel, chatID, @@ -374,7 +411,8 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return fmt.Sprintf("Error: %v", err) } - // Response is automatically sent via MessageBus by AgentLoop - _ = response // Will be sent by AgentLoop + if response != "" { + t.executor.PublishResponseIfNeeded(ctx, channel, chatID, response) + } return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index cd7d39860..186c6a75e 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "fmt" "path/filepath" "strings" "testing" @@ -12,18 +13,59 @@ import ( "github.com/sipeed/picoclaw/pkg/cron" ) -func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { +type stubJobExecutor struct { + response string + err error + alreadySent bool // simulate message tool having already sent in this round + lastPrompt string + lastKey string + lastChan string + lastChatID string + publishedResp string + publishedChan string + publishedChatID string +} + +func (s *stubJobExecutor) ProcessDirectWithChannel( + _ context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { + s.lastPrompt = content + s.lastKey = sessionKey + s.lastChan = channel + s.lastChatID = chatID + return s.response, s.err +} + +func (s *stubJobExecutor) PublishResponseIfNeeded( + _ context.Context, + channel, chatID, response string, +) { + if s.alreadySent { + return + } + s.publishedResp = response + s.publishedChan = channel + s.publishedChatID = chatID +} + +func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) msgBus := bus.NewMessageBus() - tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg) + tool, err := NewCronTool(cronService, executor, msgBus, t.TempDir(), true, 0, cfg) if err != nil { t.Fatalf("NewCronTool() error: %v", err) } return tool } +func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool { + t.Helper() + return newTestCronToolWithExecutorAndConfig(t, nil, cfg) +} + func newTestCronTool(t *testing.T) *CronTool { t.Helper() return newTestCronToolWithConfig(t, config.DefaultConfig()) @@ -237,3 +279,218 @@ func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) { t.Fatalf("expected exec disabled message, got: %s", msg.Content) } } + +func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { + executor := &stubJobExecutor{response: "generated reply"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-1"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "send me a poem" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.lastKey != "cron-job-1" { + t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey) + } + if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { + t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID) + } + if executor.lastPrompt != "send me a poem" { + t.Fatalf("prompt = %q, want original message", executor.lastPrompt) + } + if executor.publishedResp != "generated reply" { + t.Fatalf("published response = %q, want generated reply", executor.publishedResp) + } + if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { + t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) + } +} + +func TestCronTool_ExecuteJobSkipsEmptyAgentResponse(t *testing.T) { + executor := &stubJobExecutor{} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-empty"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "say nothing" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.publishedResp != "" { + t.Fatalf("unexpected published response: %q", executor.publishedResp) + } +} + +func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { + executor := &stubJobExecutor{response: "Sent.", alreadySent: true} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-msg-sent"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "send weather" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.publishedResp != "" { + t.Fatalf("expected no published response when message tool already sent, got: %q", executor.publishedResp) + } +} + +func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) { + executor := &stubJobExecutor{response: "directive result"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + originalMsg := "check the weather and summarize" + job := &cron.CronJob{ID: "job-dir-1"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = originalMsg + job.Payload.Type = "directive" + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg + if executor.lastPrompt != wantPrompt { + t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt) + } + if executor.publishedResp != "directive result" { + t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result") + } +} + +func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) { + executor := &stubJobExecutor{response: "agent processed"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-dir-deliver"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "generate daily report" + job.Payload.Type = "directive" + job.Payload.Deliver = true + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.lastPrompt == "" { + t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked") + } + if executor.publishedResp != "agent processed" { + t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") + } + + // Verify no direct publish happened on the bus (agent path, not direct path) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + select { + case msg := <-tool.msgBus.OutboundChan(): + t.Fatalf("unexpected direct bus message: %+v", msg) + case <-ctx.Done(): + // expected: no direct bus message + } +} + +func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) { + executor := &stubJobExecutor{response: "should not be called"} + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-deliver"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "hello world" + job.Payload.Deliver = true + + if got := tool.ExecuteJob(context.Background(), job); got != "ok" { + t.Fatalf("ExecuteJob() = %q, want ok", got) + } + + if executor.lastPrompt != "" { + t.Fatal("expected agent NOT to be invoked for deliver=true message type") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + select { + case msg := <-tool.msgBus.OutboundChan(): + if msg.Content != "hello world" { + t.Fatalf("bus content = %q, want %q", msg.Content, "hello world") + } + case <-ctx.Done(): + t.Fatal("timeout waiting for direct bus message") + } +} + +func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { + executor := &stubJobExecutor{ + response: "this response must not be published", + err: fmt.Errorf("agent failure"), + } + tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) + + job := &cron.CronJob{ID: "job-err"} + job.Payload.Channel = "telegram" + job.Payload.To = "chat-1" + job.Payload.Message = "do something" + + got := tool.ExecuteJob(context.Background(), job) + if !strings.Contains(got, "agent failure") { + t.Fatalf("ExecuteJob() = %q, want error message", got) + } + + if executor.publishedResp != "" { + t.Fatalf("unexpected publish on error path: %q", executor.publishedResp) + } +} + +func TestCronTool_AddJobRejectsInvalidType(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "test", + "at_seconds": float64(60), + "type": "invalid_type", + }) + + if !result.IsError { + t.Fatal("expected error for invalid type parameter") + } + if !strings.Contains(result.ForLLM, "invalid type") { + t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM) + } +} + +func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) { + for _, msgType := range []string{"", "message", "directive"} { + t.Run("type="+msgType, func(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + args := map[string]any{ + "action": "add", + "message": "test", + "at_seconds": float64(60), + } + if msgType != "" { + args["type"] = msgType + } + + result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM) + } + }) + } +} From e70928cc6f4fcf88f4f7a34ba18a7126824b42c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=85=89=E6=98=A5?= Date: Sun, 29 Mar 2026 14:37:22 +0800 Subject: [PATCH 18/72] feat(mcp): support DisableStandaloneSSE for HTTP transport (#2108) --- pkg/mcp/manager.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 7b63cc979..323df0312 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -276,14 +276,25 @@ func (m *Manager) ConnectServer( if cfg.URL == "" { return fmt.Errorf("URL is required for SSE/HTTP transport") } + + // Configure DisableStandaloneSSE based on transport type. + // - "http": Request-response only mode. Disable the standalone SSE stream + // to avoid compatibility issues with servers that don't support GET /mcp. + // - "sse": Bidirectional mode. Enable the standalone SSE stream to receive + // server-initiated notifications (e.g., ToolListChangedNotification). + // - Empty or auto-detected: Defaults to "sse" behavior (standalone SSE enabled). + disableStandaloneSSE := (cfg.Type == "http") + logger.DebugCF("mcp", "Using SSE/HTTP transport", map[string]any{ - "server": name, - "url": cfg.URL, + "server": name, + "url": cfg.URL, + "disableStandaloneSSE": disableStandaloneSSE, }) sseTransport := &mcp.StreamableClientTransport{ - Endpoint: cfg.URL, + Endpoint: cfg.URL, + DisableStandaloneSSE: disableStandaloneSSE, } // Add custom headers if provided From f0c0219c4cd6cc86b12b8635c185dc0a4327a225 Mon Sep 17 00:00:00 2001 From: Cytown Date: Sun, 29 Mar 2026 16:58:48 +0800 Subject: [PATCH 19/72] fix for review --- pkg/channels/pico/protocol.go | 2 ++ pkg/gateway/gateway.go | 8 +++----- web/backend/api/gateway.go | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 0a630e193..192c96164 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -17,6 +17,8 @@ const ( TypeTypingStop = "typing.stop" TypeError = "error" TypePong = "pong" + + PicoTokenPrefix = "pico-" ) // PicoMessage is the wire format for all Pico Protocol messages. diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index a63530806..1a9ab4461 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -22,7 +22,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" - _ "github.com/sipeed/picoclaw/pkg/channels/pico" + "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" @@ -662,8 +662,6 @@ func setupCronTool( return cronService, nil } -const picoTokenPrefix = "pico-" - // overridePicoToken replaces the pico channel token with the one from the PID file. // The PID file is the single source of truth for the pico auth token; // it is generated once at gateway startup and remains unchanged across reloads. @@ -672,10 +670,10 @@ func overridePicoToken(cfg *config.Config, token string) { return } picoToken := cfg.Channels.Pico.Token.String() - if picoToken == "" || strings.HasPrefix(picoToken, picoTokenPrefix) { + if picoToken == "" || strings.HasPrefix(picoToken, pico.PicoTokenPrefix) { return } - cfg.Channels.Pico.SetToken(picoTokenPrefix + token + picoToken) + cfg.Channels.Pico.SetToken(pico.PicoTokenPrefix + token + picoToken) } func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 98fb77a04..ce3a9ca1e 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" @@ -59,9 +60,8 @@ func refreshPicoTokensLocked(configPath string) { } const ( - protocolKey = "Sec-Websocket-Protocol" - picoTokenPrefix = "pico-" - tokenPrefix = "token." + protocolKey = "Sec-Websocket-Protocol" + tokenPrefix = "token." ) // picoComposedToken returns "pico-"+pidToken+picoToken for gateway auth. @@ -75,7 +75,7 @@ func picoComposedToken(token string) string { if tokenPrefix+gateway.picoToken != token { return "" } - return picoTokenPrefix + gateway.pidData.Token + gateway.picoToken + return pico.PicoTokenPrefix + gateway.pidData.Token + gateway.picoToken } var ( From 42e3aaff35c6568721d8e2dc21ab6babcc32c20f Mon Sep 17 00:00:00 2001 From: Cytown Date: Sun, 29 Mar 2026 18:22:25 +0800 Subject: [PATCH 20/72] make logger more clear with highlight component and use package name for default component --- pkg/logger/logger.go | 45 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 1244addb2..f88d5ed2b 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -21,6 +21,8 @@ const ( WARN = zerolog.WarnLevel ERROR = zerolog.ErrorLevel FATAL = zerolog.FatalLevel + + Component = "component" ) var ( @@ -50,6 +52,18 @@ func init() { // Custom formatter to handle multiline strings and JSON objects FormatFieldValue: formatFieldValue, + PartsOrder: []string{ + zerolog.TimestampFieldName, + zerolog.LevelFieldName, + Component, + zerolog.CallerFieldName, + zerolog.MessageFieldName, + }, + FieldsExclude: []string{Component}, + FormatPrepare: func(fields map[string]any) error { + fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component]) + return nil + }, } logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() @@ -193,7 +207,19 @@ func ConfigureFromEnv() { } } -func getCallerSkip() int { +func getPackageNameFromFile(filePath string) string { + dir := filepath.Dir(filePath) + importPath := filepath.ToSlash(dir) + + parts := strings.Split(importPath, "/") + if len(parts) == 0 { + return "" + } + + return parts[len(parts)-1] +} + +func getCallerSkip() (int, string) { for i := 2; i < 15; i++ { pc, file, _, ok := runtime.Caller(i) if !ok { @@ -217,10 +243,10 @@ func getCallerSkip() int { continue } - return i - 1 + return i - 1, getPackageNameFromFile(file) } - return 3 + return 3, "" } //nolint:zerologlint @@ -246,14 +272,16 @@ func logMessage(level LogLevel, component string, message string, fields map[str return } - skip := getCallerSkip() + skip, pkg := getCallerSkip() event := getEvent(logger, level) - if component != "" { - event.Str("component", component) + if component == "" { + component = pkg } + event.Str(Component, component) + appendFields(event, fields) event.CallerSkipFrame(skip).Msg(message) @@ -261,10 +289,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str if fileLogger.GetLevel() != zerolog.NoLevel { fileEvent := getEvent(fileLogger, level) - if component != "" { - fileEvent.Str("component", component) - } - // fileEvent.Str("caller", fmt.Sprintf("%s:%d (%s)", callerFile, callerLine, callerFunc)) + fileEvent.Str(Component, component) appendFields(fileEvent, fields) fileEvent.CallerSkipFrame(skip).Msg(message) From a4574f72a3b328666782c1d143134e79f75c1f0b Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Sun, 29 Mar 2026 22:19:13 +0800 Subject: [PATCH 21/72] fix(web/config): persist Discord token updates from channel settings (#2024) * fix: save Discord token updates from channel settings - preserve secret fields from PUT/PATCH /api/config payloads via setters - include _token edit fields in channel save payload construction - add regression test for Discord token patch flow (issue #2005) * fix: resolve shadow lint warnings in config secret mapping * fix(web/api): adapt config secret patch path after #2068 --------- Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com> --- web/backend/api/config.go | 150 ++++++++++++++++++ web/backend/api/config_test.go | 36 +++++ .../channels/channel-config-page.tsx | 25 +-- 3 files changed, 200 insertions(+), 11 deletions(-) diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 0add7594d..06391b8fc 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -52,6 +52,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } if execAllowRemoteOmitted(body) { cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote } @@ -63,6 +68,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) return } + applyConfigSecretsFromMap(&cfg, raw) if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -159,6 +165,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) return } + applyConfigSecretsFromMap(&newCfg, base) if errs := validateConfig(&newCfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -325,3 +332,146 @@ func mergeMap(dst, src map[string]any) { } } } + +func asMapField(value map[string]any, key string) (map[string]any, bool) { + raw, exists := value[key] + if !exists { + return nil, false + } + m, isMap := raw.(map[string]any) + return m, isMap +} + +func getSecretString(m map[string]any, key string) (string, bool) { + if raw, exists := m[key]; exists { + s, isString := raw.(string) + if isString { + return s, true + } + } + if raw, exists := m["_"+key]; exists { + s, isString := raw.(string) + if isString { + return s, true + } + } + return "", false +} + +func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) { + channels, hasChannels := asMapField(raw, "channels") + if hasChannels { + if telegram, hasTelegram := asMapField(channels, "telegram"); hasTelegram { + if token, hasToken := getSecretString(telegram, "token"); hasToken { + cfg.Channels.Telegram.SetToken(token) + } + } + if feishu, hasFeishu := asMapField(channels, "feishu"); hasFeishu { + if appSecret, hasAppSecret := getSecretString(feishu, "app_secret"); hasAppSecret { + cfg.Channels.Feishu.AppSecret.Set(appSecret) + } + if encryptKey, hasEncryptKey := getSecretString(feishu, "encrypt_key"); hasEncryptKey { + cfg.Channels.Feishu.EncryptKey.Set(encryptKey) + } + if verificationToken, hasVerificationToken := getSecretString( + feishu, + "verification_token", + ); hasVerificationToken { + cfg.Channels.Feishu.VerificationToken.Set(verificationToken) + } + } + if discord, hasDiscord := asMapField(channels, "discord"); hasDiscord { + if token, hasToken := getSecretString(discord, "token"); hasToken { + cfg.Channels.Discord.Token.Set(token) + } + } + if weixin, hasWeixin := asMapField(channels, "weixin"); hasWeixin { + if token, hasToken := getSecretString(weixin, "token"); hasToken { + cfg.Channels.Weixin.SetToken(token) + } + } + if qq, hasQQ := asMapField(channels, "qq"); hasQQ { + if appSecret, hasAppSecret := getSecretString(qq, "app_secret"); hasAppSecret { + cfg.Channels.QQ.AppSecret.Set(appSecret) + } + } + if dingtalk, hasDingTalk := asMapField(channels, "dingtalk"); hasDingTalk { + if clientSecret, hasClientSecret := getSecretString(dingtalk, "client_secret"); hasClientSecret { + cfg.Channels.DingTalk.ClientSecret.Set(clientSecret) + } + } + if slack, hasSlack := asMapField(channels, "slack"); hasSlack { + if botToken, hasBotToken := getSecretString(slack, "bot_token"); hasBotToken { + cfg.Channels.Slack.BotToken.Set(botToken) + } + if appToken, hasAppToken := getSecretString(slack, "app_token"); hasAppToken { + cfg.Channels.Slack.AppToken.Set(appToken) + } + } + if matrix, hasMatrix := asMapField(channels, "matrix"); hasMatrix { + if accessToken, hasAccessToken := getSecretString(matrix, "access_token"); hasAccessToken { + cfg.Channels.Matrix.AccessToken.Set(accessToken) + } + } + if line, hasLine := asMapField(channels, "line"); hasLine { + if channelSecret, hasChannelSecret := getSecretString(line, "channel_secret"); hasChannelSecret { + cfg.Channels.LINE.ChannelSecret.Set(channelSecret) + } + if channelAccessToken, hasChannelAccessToken := getSecretString( + line, + "channel_access_token", + ); hasChannelAccessToken { + cfg.Channels.LINE.ChannelAccessToken.Set(channelAccessToken) + } + } + if onebot, hasOneBot := asMapField(channels, "onebot"); hasOneBot { + if accessToken, hasAccessToken := getSecretString(onebot, "access_token"); hasAccessToken { + cfg.Channels.OneBot.AccessToken.Set(accessToken) + } + } + if wecom, hasWeCom := asMapField(channels, "wecom"); hasWeCom { + if secret, hasSecret := getSecretString(wecom, "secret"); hasSecret { + cfg.Channels.WeCom.SetSecret(secret) + } + } + if pico, hasPico := asMapField(channels, "pico"); hasPico { + if token, hasToken := getSecretString(pico, "token"); hasToken { + cfg.Channels.Pico.SetToken(token) + } + } + if irc, hasIRC := asMapField(channels, "irc"); hasIRC { + if password, hasPassword := getSecretString(irc, "password"); hasPassword { + cfg.Channels.IRC.Password.Set(password) + } + if nickservPassword, hasNickservPassword := getSecretString(irc, "nickserv_password"); hasNickservPassword { + cfg.Channels.IRC.NickServPassword.Set(nickservPassword) + } + if saslPassword, hasSASLPassword := getSecretString(irc, "sasl_password"); hasSASLPassword { + cfg.Channels.IRC.SASLPassword.Set(saslPassword) + } + } + } + + tools, hasTools := asMapField(raw, "tools") + if !hasTools { + return + } + skills, hasSkills := asMapField(tools, "skills") + if !hasSkills { + return + } + if github, hasGithub := asMapField(skills, "github"); hasGithub { + if token, hasToken := getSecretString(github, "token"); hasToken { + cfg.Tools.Skills.Github.Token.Set(token) + } + } + registries, hasRegistries := asMapField(skills, "registries") + if !hasRegistries { + return + } + if clawHub, hasClawHub := asMapField(registries, "clawhub"); hasClawHub { + if authToken, hasAuthToken := getSecretString(clawHub, "auth_token"); hasAuthToken { + cfg.Tools.Skills.Registries.ClawHub.AuthToken.Set(authToken) + } + } +} diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 644284849..d3e25a7f9 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -251,6 +251,42 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { } } +func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channels": { + "discord": { + "enabled": true, + "token": "discord-test-token" + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !cfg.Channels.Discord.Enabled { + t.Fatal("discord should be enabled after PATCH") + } + if got := cfg.Channels.Discord.Token.String(); got != "discord-test-token" { + t.Fatalf("discord token = %q, want %q", got, "discord-test-token") + } +} + func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 6af821ac9..3890924e0 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -62,10 +62,8 @@ function asBool(value: unknown): boolean { function buildEditConfig(config: ChannelConfig): ChannelConfig { const edit: ChannelConfig = { ...config } - for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { - if (secretKey in config) { - edit[SECRET_FIELD_MAP[secretKey]] = "" - } + for (const editKey of Object.values(SECRET_FIELD_MAP)) { + edit[editKey] = "" } return edit } @@ -94,17 +92,22 @@ function buildSavePayload( for (const [key, value] of Object.entries(editConfig)) { if (key.startsWith("_")) continue if (key === "enabled") continue - - if (key in SECRET_FIELD_MAP) { - const editKey = SECRET_FIELD_MAP[key] - const incoming = asString(editConfig[editKey]) - payload[key] = incoming !== "" ? incoming : value - continue - } + if (key in SECRET_FIELD_MAP) continue payload[key] = value } + for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { + const incoming = asString(editConfig[editKey]) + if (incoming !== "") { + payload[secretKey] = incoming + continue + } + if (secretKey in editConfig) { + payload[secretKey] = editConfig[secretKey] + } + } + if (channel.name === "whatsapp_native") { payload.use_native = true } From 1ef0553929c73ee5c3fa4d719ff6cf4851da9244 Mon Sep 17 00:00:00 2001 From: Cytown Date: Sun, 29 Mar 2026 22:32:39 +0800 Subject: [PATCH 22/72] add logger test case for console log format for component (#2162) --- pkg/logger/logger.go | 23 +++++++++++++++++++---- pkg/logger/logger_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index f88d5ed2b..33079616a 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/rs/zerolog" + "golang.org/x/term" ) type LogLevel = zerolog.Level @@ -46,6 +47,8 @@ func init() { once.Do(func() { zerolog.SetGlobalLevel(zerolog.InfoLevel) + isTTY := term.IsTerminal(int(os.Stdout.Fd())) + consoleWriter := zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "15:04:05", // TODO: make it configurable??? @@ -61,9 +64,12 @@ func init() { }, FieldsExclude: []string{Component}, FormatPrepare: func(fields map[string]any) error { - fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component]) + if isTTY { + fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component]) + } return nil }, + NoColor: !isTTY, } logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() @@ -207,16 +213,25 @@ func ConfigureFromEnv() { } } +const ( + locUnknown = "" +) + func getPackageNameFromFile(filePath string) string { dir := filepath.Dir(filePath) importPath := filepath.ToSlash(dir) parts := strings.Split(importPath, "/") if len(parts) == 0 { - return "" + return locUnknown } - return parts[len(parts)-1] + pkg := parts[len(parts)-1] + if pkg == "." { + return "
" + } + + return pkg } func getCallerSkip() (int, string) { @@ -246,7 +261,7 @@ func getCallerSkip() (int, string) { return i - 1, getPackageNameFromFile(file) } - return 3, "" + return 3, locUnknown } //nolint:zerologlint diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 1eca72607..7a7712de0 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -406,3 +406,28 @@ func TestConfigureFromEnvNoEnv(t *testing.T) { os.Unsetenv("PICOCLAW_LOG_FILE") ConfigureFromEnv() } + +func TestGetPackageNameFromFile(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {"normal package path", "/home/user/project/pkg/logger/logger.go", "logger"}, + {"nested package", "/home/user/project/internal/service/auth/handler.go", "auth"}, + {"cmd package", "/home/user/project/cmd/server/main.go", "server"}, + {"project root returns main", "./main.go", "
"}, + {"single dot returns main", ".", "
"}, + {"single directory", "mypkg/file.go", "mypkg"}, + {"deep nesting", "/a/b/c/d/e/f.go", "e"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getPackageNameFromFile(tt.path) + if got != tt.want { + t.Errorf("getPackageNameFromFile(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} From 1fc5345857692365ad09fe3ea5f449c221a6b407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?daming=E5=A4=A7=E9=93=AD?= Date: Sun, 29 Mar 2026 22:52:34 +0800 Subject: [PATCH 23/72] refactor(cron): remove deliver and type params, unify agent execution path (#2147) The agent path now publishes to outbound bus directly (since #2100), making the deliver=true direct-to-bus shortcut and the directive type prompt wrapping redundant. All cron jobs now uniformly route through the agent. This is an intentional behavior change: old jobs with deliver=true will execute through the agent instead of bypassing it. Co-authored-by: Claude Opus 4.6 --- cmd/picoclaw/internal/cron/add.go | 4 +- cmd/picoclaw/internal/cron/add_test.go | 1 - pkg/cron/service.go | 4 - pkg/cron/service_test.go | 10 +- pkg/tools/cron.go | 61 +--------- pkg/tools/cron_test.go | 149 ------------------------- 6 files changed, 9 insertions(+), 220 deletions(-) diff --git a/cmd/picoclaw/internal/cron/add.go b/cmd/picoclaw/internal/cron/add.go index 947557d5a..f9d73089d 100644 --- a/cmd/picoclaw/internal/cron/add.go +++ b/cmd/picoclaw/internal/cron/add.go @@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command { message string every int64 cronExp string - deliver bool channel string to string ) @@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command { } cs := cron.NewCronService(storePath(), nil) - job, err := cs.AddJob(name, schedule, message, deliver, channel, to) + job, err := cs.AddJob(name, schedule, message, channel, to) if err != nil { return fmt.Errorf("error adding job: %w", err) } @@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command { cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent") cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds") cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')") - cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel") cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery") cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery") diff --git a/cmd/picoclaw/internal/cron/add_test.go b/cmd/picoclaw/internal/cron/add_test.go index 09701fab5..53875dc51 100644 --- a/cmd/picoclaw/internal/cron/add_test.go +++ b/cmd/picoclaw/internal/cron/add_test.go @@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("every")) assert.NotNil(t, cmd.Flags().Lookup("cron")) - assert.NotNil(t, cmd.Flags().Lookup("deliver")) assert.NotNil(t, cmd.Flags().Lookup("to")) assert.NotNil(t, cmd.Flags().Lookup("channel")) diff --git a/pkg/cron/service.go b/pkg/cron/service.go index c1a224013..6a8728943 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -25,10 +25,8 @@ type CronSchedule struct { type CronPayload struct { Kind string `json:"kind"` - Type string `json:"type"` Message string `json:"message"` Command string `json:"command,omitempty"` - Deliver bool `json:"deliver"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` } @@ -410,7 +408,6 @@ func (cs *CronService) AddJob( name string, schedule CronSchedule, message string, - deliver bool, channel, to string, ) (*CronJob, error) { cs.mu.Lock() @@ -429,7 +426,6 @@ func (cs *CronService) AddJob( Payload: CronPayload{ Kind: "agent_turn", Message: message, - Deliver: deliver, Channel: channel, To: to, }, diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index c55e62174..6dff3b387 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -20,7 +20,7 @@ func TestSaveStore_FilePermissions(t *testing.T) { cs := NewCronService(storePath, nil) - _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct") + _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", "cli", "direct") if err != nil { t.Fatalf("AddJob failed: %v", err) } @@ -52,7 +52,7 @@ func TestCronService_CRUD(t *testing.T) { // Test AddJob at := time.Now().Add(time.Hour).UnixMilli() - job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to") + job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to") if err != nil || job.ID == "" { t.Fatalf("AddJob failed: %v", err) } @@ -134,7 +134,7 @@ func TestCronService_ExecutionFlow(t *testing.T) { // Add a job then runs 100ms from now target := time.Now().Add(100 * time.Millisecond).UnixMilli() - job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "") + job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", "", "") // Check for job execution with a timeout success := false @@ -167,7 +167,7 @@ func TestCronService_PersistenceIntegrity(t *testing.T) { // write a job and persist cs1 := NewCronService(tmpFile, nil) at := int64(2000000000000) - cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "") + cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", "ch1", "") // check file exists if _, err := os.Stat(tmpFile); os.IsNotExist(err) { @@ -213,7 +213,7 @@ func TestCronService_ConcurrentAccess(t *testing.T) { defer wg.Done() for j := range iterations { at := time.Now().Add(time.Hour).UnixMilli() - cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "") + cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", "", "") time.Sleep(100 * time.Microsecond) } }(i) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 60d9d5e5a..c6ac3a129 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -92,7 +92,7 @@ func (t *CronTool) Parameters() map[string]any { }, "command": map[string]any{ "type": "string", - "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", + "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message.", }, "command_confirm": map[string]any{ "type": "boolean", @@ -114,15 +114,6 @@ func (t *CronTool) Parameters() map[string]any { "type": "string", "description": "Job ID (for remove/enable/disable)", }, - "type": map[string]any{ - "type": "string", - "enum": []string{"message", "directive"}, - "description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.", - }, - "deliver": map[string]any{ - "type": "boolean", - "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false", - }, }, "required": []string{"action"}, } @@ -199,18 +190,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") } - // Read deliver parameter, default to false so scheduled tasks execute through the agent - deliver := false - if d, ok := args["deliver"].(bool); ok { - deliver = d - } - - // Validate type parameter (server-side whitelist, not just LLM schema hint) - msgType, _ := args["type"].(string) - if msgType != "" && msgType != "message" && msgType != "directive" { - return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType)) - } - // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When // allow_command is disabled, explicit confirmation is required as an override. // Non-command reminders remain open to all channels. @@ -226,7 +205,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult if !t.allowCommand && !commandConfirm { return ErrorResult("command_confirm=true is required when allow_command is disabled") } - deliver = false } // Truncate message for job name (max 30 chars) @@ -236,7 +214,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult messagePreview, schedule, message, - deliver, channel, chatID, ) @@ -250,10 +227,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult job.Payload.Command = command needsUpdate = true } - if msgType != "" { - job.Payload.Type = msgType - needsUpdate = true - } if needsUpdate { t.cronService.UpdateJob(job) } @@ -369,40 +342,12 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return "ok" } - // Determine message generation strategy - // Type="directive": treat message as instructions for AI agent to execute - // Type="" or "message" (default): static message content - isDirective := job.Payload.Type == "directive" - - // If deliver=true and not directive, send message directly without agent processing - if job.Payload.Deliver && !isDirective { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: job.Payload.Message, - }) - return "ok" - } - - // For deliver=false OR directive mode, process through agent sessionKey := fmt.Sprintf("cron-%s", job.ID) - // Prepare the prompt based on type - prompt := job.Payload.Message - if isDirective { - // For directive type, prefix to clarify this is an instruction - prompt = fmt.Sprintf( - "Please execute the following directive and provide the result:\n\n%s", - job.Payload.Message, - ) - } - - // Call agent with the prepared prompt + // Call agent with the job message response, err := t.executor.ProcessDirectWithChannel( ctx, - prompt, + job.Payload.Message, sessionKey, channel, chatID, diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index 186c6a75e..c699908cd 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -229,28 +229,6 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { } } -func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) { - tool := newTestCronTool(t) - ctx := WithToolContext(context.Background(), "telegram", "chat-1") - result := tool.Execute(ctx, map[string]any{ - "action": "add", - "message": "send me a poem", - "at_seconds": float64(600), - }) - - if result.IsError { - t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM) - } - - jobs := tool.cronService.ListJobs(false) - if len(jobs) != 1 { - t.Fatalf("expected 1 job, got %d", len(jobs)) - } - if jobs[0].Payload.Deliver { - t.Fatal("expected deliver=false by default for non-command jobs") - } -} - func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Exec.Enabled = false @@ -346,93 +324,6 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { } } -func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) { - executor := &stubJobExecutor{response: "directive result"} - tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) - - originalMsg := "check the weather and summarize" - job := &cron.CronJob{ID: "job-dir-1"} - job.Payload.Channel = "telegram" - job.Payload.To = "chat-1" - job.Payload.Message = originalMsg - job.Payload.Type = "directive" - - if got := tool.ExecuteJob(context.Background(), job); got != "ok" { - t.Fatalf("ExecuteJob() = %q, want ok", got) - } - - wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg - if executor.lastPrompt != wantPrompt { - t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt) - } - if executor.publishedResp != "directive result" { - t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result") - } -} - -func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) { - executor := &stubJobExecutor{response: "agent processed"} - tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) - - job := &cron.CronJob{ID: "job-dir-deliver"} - job.Payload.Channel = "telegram" - job.Payload.To = "chat-1" - job.Payload.Message = "generate daily report" - job.Payload.Type = "directive" - job.Payload.Deliver = true - - if got := tool.ExecuteJob(context.Background(), job); got != "ok" { - t.Fatalf("ExecuteJob() = %q, want ok", got) - } - - if executor.lastPrompt == "" { - t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked") - } - if executor.publishedResp != "agent processed" { - t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") - } - - // Verify no direct publish happened on the bus (agent path, not direct path) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - select { - case msg := <-tool.msgBus.OutboundChan(): - t.Fatalf("unexpected direct bus message: %+v", msg) - case <-ctx.Done(): - // expected: no direct bus message - } -} - -func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) { - executor := &stubJobExecutor{response: "should not be called"} - tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig()) - - job := &cron.CronJob{ID: "job-deliver"} - job.Payload.Channel = "telegram" - job.Payload.To = "chat-1" - job.Payload.Message = "hello world" - job.Payload.Deliver = true - - if got := tool.ExecuteJob(context.Background(), job); got != "ok" { - t.Fatalf("ExecuteJob() = %q, want ok", got) - } - - if executor.lastPrompt != "" { - t.Fatal("expected agent NOT to be invoked for deliver=true message type") - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - select { - case msg := <-tool.msgBus.OutboundChan(): - if msg.Content != "hello world" { - t.Fatalf("bus content = %q, want %q", msg.Content, "hello world") - } - case <-ctx.Done(): - t.Fatal("timeout waiting for direct bus message") - } -} - func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { executor := &stubJobExecutor{ response: "this response must not be published", @@ -454,43 +345,3 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) { t.Fatalf("unexpected publish on error path: %q", executor.publishedResp) } } - -func TestCronTool_AddJobRejectsInvalidType(t *testing.T) { - tool := newTestCronTool(t) - ctx := WithToolContext(context.Background(), "cli", "direct") - result := tool.Execute(ctx, map[string]any{ - "action": "add", - "message": "test", - "at_seconds": float64(60), - "type": "invalid_type", - }) - - if !result.IsError { - t.Fatal("expected error for invalid type parameter") - } - if !strings.Contains(result.ForLLM, "invalid type") { - t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM) - } -} - -func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) { - for _, msgType := range []string{"", "message", "directive"} { - t.Run("type="+msgType, func(t *testing.T) { - tool := newTestCronTool(t) - ctx := WithToolContext(context.Background(), "cli", "direct") - args := map[string]any{ - "action": "add", - "message": "test", - "at_seconds": float64(60), - } - if msgType != "" { - args["type"] = msgType - } - - result := tool.Execute(ctx, args) - if result.IsError { - t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM) - } - }) - } -} From 93f4c4a843d216ab044c48f91b72b86853de828c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E9=9D=92=E5=B7=9D?= <46062972+ShenQingchuan@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:33:08 +0800 Subject: [PATCH 24/72] fix(web): skills page uses theme colors for dark mode (#2166) - Remove bg-white/80 override on skill cards so bg-card/text-card-foreground apply - Use bg-muted + text-foreground for skill path block readability Made-with: Cursor --- web/frontend/src/components/skills/skills-page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/frontend/src/components/skills/skills-page.tsx b/web/frontend/src/components/skills/skills-page.tsx index d8eeb1d93..7e0d66d47 100644 --- a/web/frontend/src/components/skills/skills-page.tsx +++ b/web/frontend/src/components/skills/skills-page.tsx @@ -169,7 +169,7 @@ export function SkillsPage() { {data.skills.map((skill) => ( @@ -211,7 +211,7 @@ export function SkillsPage() {
{t("pages.agent.skills.path")}
-
+
{skill.path}
From cd3f6600ca6661d6fffaae3ac0bc60f05e3fb233 Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:04:51 +0800 Subject: [PATCH 25/72] fix(utils): honor Retry-After for 429 retries --- pkg/utils/http_retry.go | 29 +++++++++++++- pkg/utils/http_retry_test.go | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go index 135ea0ef5..812271290 100644 --- a/pkg/utils/http_retry.go +++ b/pkg/utils/http_retry.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strconv" "time" ) @@ -36,7 +37,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, } if i < maxRetries-1 { - if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil { + if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil { if resp != nil { resp.Body.Close() } @@ -47,6 +48,32 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, return resp, err } +func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration { + fallback := retryDelayUnit * time.Duration(attempt+1) + if resp == nil || resp.StatusCode != http.StatusTooManyRequests { + return fallback + } + + retryAfter := resp.Header.Get("Retry-After") + if retryAfter == "" { + return fallback + } + + if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 { + return time.Duration(seconds) * time.Second + } + + if when, err := http.ParseTime(retryAfter); err == nil { + delay := time.Until(when) + if delay < 0 { + return 0 + } + return delay + } + + return fallback +} + func sleepWithCtx(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go index d64cd5eda..918245b57 100644 --- a/pkg/utils/http_retry_test.go +++ b/pkg/utils/http_retry_test.go @@ -80,6 +80,81 @@ func TestDoRequestWithRetry(t *testing.T) { } } +func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) { + retryDelayUnit = 10 * time.Millisecond + t.Cleanup(func() { retryDelayUnit = time.Second }) + + attempts := 0 + var firstAttemptAt time.Time + var secondAttemptAt time.Time + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + firstAttemptAt = time.Now() + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + if attempts == 2 { + secondAttemptAt = time.Now() + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := DoRequestWithRetry(client, req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + require.Equal(t, 2, attempts) + + assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond) +} + +func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) { + retryDelayUnit = 50 * time.Millisecond + t.Cleanup(func() { retryDelayUnit = time.Second }) + + attempts := 0 + var firstAttemptAt time.Time + var secondAttemptAt time.Time + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + firstAttemptAt = time.Now() + w.Header().Set("Retry-After", "invalid") + w.WriteHeader(http.StatusTooManyRequests) + return + } + if attempts == 2 { + secondAttemptAt = time.Now() + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := DoRequestWithRetry(client, req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + require.Equal(t, 2, attempts) + + assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond) + assert.Less(t, secondAttemptAt.Sub(firstAttemptAt), 500*time.Millisecond) +} + func TestDoRequestWithRetry_ContextCancel(t *testing.T) { // Use a long retry delay so cancellation always hits during sleepWithCtx. retryDelayUnit = 10 * time.Second From 93757812fc64e225c89e1d33ed9e5d0504ad4d75 Mon Sep 17 00:00:00 2001 From: Cytown Date: Mon, 30 Mar 2026 14:01:20 +0800 Subject: [PATCH 26/72] refactor config and add ModelConfig.Enabled --- cmd/picoclaw/internal/model/command.go | 4 +- cmd/picoclaw/internal/model/command_test.go | 34 +- pkg/config/config.go | 182 +++----- pkg/config/config_old.go | 62 ++- pkg/config/config_struct.go | 327 ++++++++++++++ pkg/config/config_struct_test.go | 145 ++++++ pkg/config/config_test.go | 160 +++++++ pkg/config/migration.go | 23 + pkg/config/migration_integration_test.go | 470 ++++++++++++++++++++ pkg/config/multikey_test.go | 2 +- pkg/config/security.go | 236 ---------- pkg/config/security_test.go | 133 ------ web/backend/api/models.go | 2 + 13 files changed, 1272 insertions(+), 508 deletions(-) create mode 100644 pkg/config/config_struct.go create mode 100644 pkg/config/config_struct_test.go diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go index 314259d0f..330734b82 100644 --- a/cmd/picoclaw/internal/model/command.go +++ b/cmd/picoclaw/internal/model/command.go @@ -81,7 +81,7 @@ func listAvailableModels(cfg *config.Config) { if model.ModelName == defaultModel { marker = "> " } - if model.APIKey() == "" { + if !model.Enabled { continue } fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model) @@ -92,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er // Validate that the model exists in model_list modelFound := false for _, model := range cfg.ModelList { - if model.APIKey() != "" && model.ModelName == modelName { + if model.Enabled && model.ModelName == modelName { modelFound = true break } diff --git a/cmd/picoclaw/internal/model/command_test.go b/cmd/picoclaw/internal/model/command_test.go index 8be29ba95..9e2a7bbae 100644 --- a/cmd/picoclaw/internal/model/command_test.go +++ b/cmd/picoclaw/internal/model/command_test.go @@ -65,11 +65,17 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) { }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, { ModelName: "claude-3", Model: "anthropic/claude-3", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -92,7 +98,12 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) { }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, }, } @@ -124,11 +135,17 @@ func TestListAvailableModels_WithModels(t *testing.T) { }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + ModelName: "gpt-4", + Model: "openai/gpt-4", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, + }, { ModelName: "claude-3", Model: "anthropic/claude-3", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, {ModelName: "no-key-model", Model: "openai/test"}, }, @@ -158,11 +175,13 @@ func TestSetDefaultModel_ValidModel(t *testing.T) { ModelName: "new-model", Model: "openai/new-model", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, { ModelName: "old-model", Model: "openai/old-model", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -194,6 +213,7 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) { ModelName: "existing-model", Model: "openai/existing", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -215,6 +235,7 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) { ModelName: "existing-model", Model: "openai/existing", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, {ModelName: "no-key-model", Model: "openai/nokey"}, }, @@ -238,6 +259,7 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) { ModelName: "new-model", Model: "openai/new-model", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -283,6 +305,7 @@ func TestModelCommandExecution_Show(t *testing.T) { ModelName: "test-model", Model: "openai/test", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -314,11 +337,13 @@ func TestModelCommandExecution_Set(t *testing.T) { ModelName: "old-model", Model: "openai/old", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, { ModelName: "new-model", Model: "openai/new", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } @@ -356,16 +381,19 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) { ModelName: "first-model", Model: "openai/first", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, { ModelName: "middle-model", Model: "openai/middle", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, { ModelName: "last-model", Model: "openai/last", APIKeys: config.SecureStrings{config.NewSecureString("test")}, + Enabled: true, }, }, } diff --git a/pkg/config/config.go b/pkg/config/config.go index 533f45a44..3dc3422fb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,8 +7,8 @@ import ( "math/rand" "os" "path/filepath" - "strings" "sync/atomic" + "time" "github.com/caarlos0/env/v11" @@ -20,89 +20,8 @@ import ( // rrCounter is a global counter for round-robin load balancing across models. var rrCounter atomic.Uint64 -// FlexibleStringSlice is a []string that also accepts JSON numbers, -// so allow_from can contain both "123" and 123. -// It also supports parsing comma-separated strings from environment variables, -// including both English (,) and Chinese (,) commas. -type FlexibleStringSlice []string - -func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { - // Accept a single JSON string for convenience, e.g.: - // "text": "Thinking..." - var singleString string - if err := json.Unmarshal(data, &singleString); err == nil { - *f = FlexibleStringSlice{singleString} - return nil - } - - // Accept a single JSON number too, to keep symmetry with mixed allow_from - // payloads that may contain numeric identifiers. - var singleNumber float64 - if err := json.Unmarshal(data, &singleNumber); err == nil { - *f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)} - return nil - } - - // Try []string first - var ss []string - if err := json.Unmarshal(data, &ss); err == nil { - *f = ss - return nil - } - - // Try []interface{} to handle mixed types - var raw []any - if err := json.Unmarshal(data, &raw); err != nil { - var s string - // fail over to compatible to old format string - if err = json.Unmarshal(data, &s); err != nil { - return err - } - *f = []string{s} - return nil - } - - result := make([]string, 0, len(raw)) - for _, v := range raw { - switch val := v.(type) { - case string: - result = append(result, val) - case float64: - result = append(result, fmt.Sprintf("%.0f", val)) - default: - result = append(result, fmt.Sprintf("%v", val)) - } - } - *f = result - return nil -} - -// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. -// It handles comma-separated values with both English (,) and Chinese (,) commas. -func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { - if len(text) == 0 { - *f = nil - return nil - } - - s := string(text) - // Replace Chinese comma with English comma, then split - s = strings.ReplaceAll(s, ",", ",") - parts := strings.Split(s, ",") - - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - *f = result - return nil -} - // CurrentVersion is the latest config schema version -const CurrentVersion = 1 +const CurrentVersion = 2 // Config is the current config structure with version support type Config struct { @@ -675,6 +594,11 @@ type ModelConfig struct { APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) + // Enabled indicates whether this model entry is active. When omitted in + // existing configs, the field is inferred during load: models with API keys + // or the reserved "local-model" name are auto-enabled. + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // isVirtual marks this model as a virtual model generated from multi-key expansion. // Virtual models should not be persisted to config files. isVirtual bool @@ -1047,6 +971,35 @@ func LoadConfig(path string) (*Config, error) { defer func(cfg *Config) { _ = SaveConfig(path, cfg) }(cfg) + case 1: + // V1→V2 migration: infer Enabled and migrate channel config fields + logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + cfg, err = loadConfig(data) + if err != nil { + return nil, err + } + secPath := securityPath(path) + err = loadSecurityConfig(cfg, secPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("failed to load security config: %w", err) + } + + oldCfg := &configV1{Config: *cfg} + cfg, err = oldCfg.Migrate() + if err != nil { + logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + return nil, err + } + + err = makeBackup(path) + if err != nil { + return nil, err + } + + defer func(cfg *Config) { + _ = SaveConfig(path, cfg) + }(cfg) + logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) case CurrentVersion: // Current version cfg, err = loadConfig(data) @@ -1064,18 +1017,15 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } - if err := env.Parse(cfg); err != nil { + if err = env.Parse(cfg); err != nil { return nil, err } // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) - // Migrate legacy channel config fields to new unified structures - cfg.migrateChannelConfigs() - // Validate model_list for uniqueness and required fields - if err := cfg.ValidateModelList(); err != nil { + if err = cfg.ValidateModelList(); err != nil { return nil, err } @@ -1097,12 +1047,22 @@ func makeBackup(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return nil } - // Create backup of the config file before migration - bakPath := path + ".bak" + dateSuffix := time.Now().Format(".20060102.bak") + // Backup config file + bakPath := path + dateSuffix if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil { logger.ErrorF("failed to create config backup", map[string]any{"error": err}) return fmt.Errorf("failed to create config backup: %w", err) } + // Backup security config file + secPath := securityPath(path) + if _, err := os.Stat(secPath); err == nil { + secBakPath := secPath + dateSuffix + if secErr := fileutil.CopyFile(secPath, secBakPath, 0o600); secErr != nil { + logger.ErrorF("failed to create security backup", map[string]any{"error": secErr}) + return fmt.Errorf("failed to create security backup: %w", secErr) + } + } return nil } @@ -1118,19 +1078,6 @@ func toNameIndex(list []*ModelConfig) []string { return nameList } -func (c *Config) migrateChannelConfigs() { - // Discord: mention_only -> group_trigger.mention_only - if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { - c.Channels.Discord.GroupTrigger.MentionOnly = true - } - - // OneBot: group_trigger_prefix -> group_trigger.prefixes - if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && - len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { - c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix - } -} - func SaveConfig(path string, cfg *Config) error { if cfg.Version < CurrentVersion { cfg.Version = CurrentVersion @@ -1144,6 +1091,10 @@ func SaveConfig(path string, cfg *Config) error { } // Temporarily replace ModelList with filtered version for serialization originalModelList := cfg.ModelList + defer func() { + // Restore original ModelList after serialization + cfg.ModelList = originalModelList + }() cfg.ModelList = nonVirtualModels if err := saveSecurityConfig(securityPath(path), cfg); err != nil { @@ -1152,8 +1103,6 @@ func SaveConfig(path string, cfg *Config) error { } data, err := json.MarshalIndent(cfg, "", " ") - // Restore original ModelList after serialization - cfg.ModelList = originalModelList if err != nil { return err } @@ -1223,29 +1172,6 @@ func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } -func MergeAPIKeys(apiKey string, apiKeys []string) []string { - seen := make(map[string]struct{}) - var all []string - - if k := strings.TrimSpace(apiKey); k != "" { - if _, exists := seen[k]; !exists { - seen[k] = struct{}{} - all = append(all, k) - } - } - - for _, k := range apiKeys { - if trimmed := strings.TrimSpace(k); trimmed != "" { - if _, exists := seen[trimmed]; !exists { - seen[trimmed] = struct{}{} - all = append(all, trimmed) - } - } - } - - return all -} - // expandMultiKeyModels expands ModelConfig entries with multiple API keys into // separate entries for key-level failover. Each key gets its own ModelConfig entry, // and the original entry's fallbacks are set up to chain through the expanded entries. diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go index fd54c9e08..150275aac 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -734,7 +734,8 @@ func (c *configV0) Migrate() (*Config, error) { // Convert []modelConfigV0 to []ModelConfig cfg.ModelList = make([]*ModelConfig, len(c.ModelList)) for i, m := range c.ModelList { - cfg.ModelList[i] = &ModelConfig{ + mergedKeys := toSecureStrings(mergeAPIKeys(m.APIKey, m.APIKeys)) + mc := &ModelConfig{ ModelName: m.ModelName, Model: m.Model, APIBase: m.APIBase, @@ -747,8 +748,13 @@ func (c *configV0) Migrate() (*Config, error) { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, - APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)), + APIKeys: mergedKeys, } + // Infer Enabled during V0→V1 migration + if len(mergedKeys) > 0 || m.ModelName == "local-model" { + mc.Enabled = true + } + cfg.ModelList[i] = mc } } @@ -756,6 +762,52 @@ func (c *configV0) Migrate() (*Config, error) { return cfg, nil } +type configV1 struct { + Config +} + +// Migrate applies V1→Current Version migrations to an already-loaded Config. +// +// It must be called AFTER loadSecurityConfig so that API keys (which live in +// the security file) are available for the Enabled inference. +func (c *configV1) Migrate() (*Config, error) { + c.migrateModelEnabled() + c.migrateChannelConfigs() + return &c.Config, nil +} + +// migrateModelEnabled infers the Enabled field for models loaded from V1 configs +// that predate the field (JSON where "enabled" is absent). +// +// Rules (only applied when Enabled has not been explicitly set by the user): +// - Models with API keys are considered enabled. +// - The reserved "local-model" entry is considered enabled. +func (cfg *configV1) migrateModelEnabled() { + for _, m := range cfg.ModelList { + if m.Enabled { + continue + } + if len(m.APIKeys) > 0 || m.ModelName == "local-model" { + m.Enabled = true + } + } +} + +// migrateChannelConfigs migrates legacy channel config fields in a V1 Config +// to the new unified structures. +func (cfg *configV1) migrateChannelConfigs() { + // Discord: mention_only -> group_trigger.mention_only + if cfg.Channels.Discord.MentionOnly && !cfg.Channels.Discord.GroupTrigger.MentionOnly { + cfg.Channels.Discord.GroupTrigger.MentionOnly = true + } + + // OneBot: group_trigger_prefix -> group_trigger.prefixes + if len(cfg.Channels.OneBot.GroupTriggerPrefix) > 0 && + len(cfg.Channels.OneBot.GroupTrigger.Prefixes) == 0 { + cfg.Channels.OneBot.GroupTrigger.Prefixes = cfg.Channels.OneBot.GroupTriggerPrefix + } +} + type webToolsConfigV0 struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave braveConfigV0 ` json:"brave"` @@ -791,7 +843,7 @@ func (v *braveConfigV0) ToBraveConfig() BraveConfig { return BraveConfig{ Enabled: v.Enabled, MaxResults: v.MaxResults, - APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)), } } @@ -808,7 +860,7 @@ func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig { Enabled: v.Enabled, BaseURL: v.BaseURL, MaxResults: v.MaxResults, - APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)), } } @@ -823,7 +875,7 @@ func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig { return PerplexityConfig{ Enabled: v.Enabled, MaxResults: v.MaxResults, - APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)), } } diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go new file mode 100644 index 000000000..0b8dd85c8 --- /dev/null +++ b/pkg/config/config_struct.go @@ -0,0 +1,327 @@ +package config + +import ( + "encoding/json" + "fmt" + "path/filepath" + "runtime" + "strings" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// FlexibleStringSlice is a []string that also accepts JSON numbers, +// so allow_from can contain both "123" and 123. +// It also supports parsing comma-separated strings from environment variables, +// including both English (,) and Chinese (,) commas. +type FlexibleStringSlice []string + +func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + // Accept a single JSON string for convenience, e.g.: + // "text": "Thinking..." + var singleString string + if err := json.Unmarshal(data, &singleString); err == nil { + *f = FlexibleStringSlice{singleString} + return nil + } + + // Accept a single JSON number too, to keep symmetry with mixed allow_from + // payloads that may contain numeric identifiers. + var singleNumber float64 + if err := json.Unmarshal(data, &singleNumber); err == nil { + *f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)} + return nil + } + + // Try []string first + var ss []string + if err := json.Unmarshal(data, &ss); err == nil { + *f = ss + return nil + } + + // Try []interface{} to handle mixed types + var raw []any + if err := json.Unmarshal(data, &raw); err != nil { + var s string + // fail over to compatible to old format string + if err = json.Unmarshal(data, &s); err != nil { + return err + } + *f = []string{s} + return nil + } + + result := make([]string, 0, len(raw)) + for _, v := range raw { + switch val := v.(type) { + case string: + result = append(result, val) + case float64: + result = append(result, fmt.Sprintf("%.0f", val)) + default: + result = append(result, fmt.Sprintf("%v", val)) + } + } + *f = result + return nil +} + +// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. +// It handles comma-separated values with both English (,) and Chinese (,) commas. +func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { + if len(text) == 0 { + *f = nil + return nil + } + + s := string(text) + // Replace Chinese comma with English comma, then split + s = strings.ReplaceAll(s, ",", ",") + parts := strings.Split(s, ",") + + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + *f = result + return nil +} + +const ( + notHere = `"[NOT_HERE]"` +) + +// SecureStrings is a slice of SecureString +type SecureStrings []*SecureString + +// Values returns the decrypted/resolved values +func (s *SecureStrings) Values() []string { + if s == nil { + return nil + } + keys := make([]string, len(*s)) + for i, k := range *s { + keys[i] = k.String() + } + return unique(keys) +} + +func SimpleSecureStrings(val ...string) SecureStrings { + val = unique(val) + vv := make(SecureStrings, len(val)) + for i, s := range val { + vv[i] = NewSecureString(s) + } + return vv +} + +// unique returns a new slice with duplicate elements removed. +func unique[T comparable](input []T) []T { + m := make(map[T]struct{}) + var result []T + for _, v := range input { + if _, ok := m[v]; !ok { + m[v] = struct{}{} + result = append(result, v) + } + } + return result +} + +func (s SecureStrings) MarshalJSON() ([]byte, error) { + return []byte(notHere), nil +} + +func (s *SecureStrings) UnmarshalJSON(value []byte) error { + if string(value) == notHere { + return nil + } + var v []*SecureString + err := json.Unmarshal(value, &v) + if err != nil { + return err + } + *s = v + return nil +} + +// SecureString the string value that can be decrypted or resolved +// +//nolint:recvcheck +type SecureString struct { + resolved string // Decrypted/resolved value returned by String() + raw string // Persisted raw value (enc://, file://, or plaintext) +} + +func callerFromYaml() bool { + _, file, _, ok := runtime.Caller(2) + if ok { + d := filepath.Dir(file) + // check the caller is from yaml.v + if !strings.Contains(d, "yaml.v") { + return true + } + } + return false +} + +// IsZero returns true if the SecureString is empty +// if caller not yaml, just return true for prevent marshal this field +func (s SecureString) IsZero() bool { + if callerFromYaml() { + return true + } + return s.resolved == "" +} + +func NewSecureString(value string) *SecureString { + s := &SecureString{} + if err := s.fromRaw(value); err != nil { + logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err)) + } + return s +} + +func (s *SecureString) String() string { + if s == nil { + return "" + } + return s.resolved +} + +func (s *SecureString) Set(value string) *SecureString { + s.resolved = value + s.raw = "" + return s +} + +func (s SecureString) MarshalJSON() ([]byte, error) { + return []byte(notHere), nil +} + +func (s *SecureString) UnmarshalJSON(value []byte) error { + if string(value) == notHere { + return nil + } + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + return s.fromRaw(v) +} + +func (s SecureString) MarshalYAML() (any, error) { + // Preserve raw value if it is already a reference (enc:// or file://) + if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { + return s.raw, nil + } + // If resolved is a reference format (e.g. set via Set), copy back to raw + if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) { + s.raw = s.resolved + return s.raw, nil + } + // Try to encrypt the resolved value + if passphrase := credential.PassphraseProvider(); passphrase != "" { + encrypted, err := credential.Encrypt(passphrase, "", s.resolved) + if err != nil { + logger.Errorf("Encrypt error: %v", err) + return nil, err + } + s.raw = encrypted + } else { + s.raw = s.resolved + } + return s.raw, nil +} + +func (s *SecureString) UnmarshalYAML(value *yaml.Node) error { + return s.fromRaw(value.Value) +} + +func (s *SecureString) fromRaw(v string) error { + s.raw = v + vv, err := resolveKey(v) + if err != nil { + return err + } + s.resolved = vv + return nil +} + +var ( + secResolverMu sync.RWMutex + secResolver *credential.Resolver +) + +func updateResolver(path string) { + secResolverMu.Lock() + defer secResolverMu.Unlock() + secResolver = credential.NewResolver(path) +} + +func resolveKey(v string) (string, error) { + secResolverMu.RLock() + resolver := secResolver + secResolverMu.RUnlock() + if resolver == nil { + resolver = credential.NewResolver("") + } + if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") { + decrypted, err := resolver.Resolve(v) + if err != nil { + logger.Errorf("Resolve error: %v", err) + return "", err + } + return decrypted, nil + } + return v, nil +} + +func (s *SecureString) UnmarshalText(text []byte) error { + v := string(text) + return s.fromRaw(v) +} + +type SecureModelList []*ModelConfig + +func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error { + mm := make(map[string]*ModelConfig) + if err := value.Decode(&mm); err != nil { + logger.Errorf("Decode error: %v", err) + return err + } + nameList := toNameIndex(*v) + for i, m := range *v { + sec := mm[nameList[i]] + if sec == nil { + sec = mm[m.ModelName] + } + if sec != nil { + m.APIKeys = sec.APIKeys + } + } + return nil +} + +func (v SecureModelList) MarshalYAML() (any, error) { + type onlySecureData struct { + APIKeys SecureStrings `yaml:"api_keys,omitempty"` + } + mm := make(map[string]onlySecureData) + nameList := toNameIndex(v) + for i, m := range v { + mm[nameList[i]] = onlySecureData{ + APIKeys: m.APIKeys, + } + } + + return mm, nil +} diff --git a/pkg/config/config_struct_test.go b/pkg/config/config_struct_test.go new file mode 100644 index 000000000..674b6a064 --- /dev/null +++ b/pkg/config/config_struct_test.go @@ -0,0 +1,145 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/caarlos0/env/v11" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestLoadSecurityValue(t *testing.T) { + type valueStruct struct { + Url string `json:"url,omitempty" yaml:"-"` + Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"` + ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"` + } + + type testStruct struct { + Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` + } + + v1 := &testStruct{ + Pico: &valueStruct{ + Url: "https://example.com", + Token: NewSecureString("token1"), + ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, + }, + } + bytes, err := yaml.Marshal(v1) + assert.NoError(t, err) + jsonBytes, err := json.Marshal(v1) + assert.NoError(t, err) + const want = `pico: + token: token1 + api_keys: + - api-key1 + - api-key2 +` + const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}` + v0 := &testStruct{} + err = json.Unmarshal([]byte(jsonPost), v0) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v0.Pico.Url) + assert.Equal(t, "token0", v0.Pico.Token.String()) + + const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}` + assert.Equal(t, want, string(bytes)) + assert.Equal(t, jsonWant, string(jsonBytes)) + + v2 := &testStruct{} + err = json.Unmarshal(jsonBytes, v2) + assert.NoError(t, err) + err = yaml.Unmarshal(bytes, v2) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v2.Pico.Url) + if v2.Pico.Token != nil { + assert.Equal(t, "token1", v2.Pico.Token.String()) + assert.Equal(t, "token1", v2.Pico.Token.raw) + } + + v2.Pico.Token = NewSecureString("token1") + v2.Pico.Token.raw = "abc" + err = yaml.Unmarshal(bytes, v2) + assert.NoError(t, err) + assert.Equal(t, "token1", v2.Pico.Token.raw) + + os.Setenv("PICO_TOKEN", "token_env") + err = env.Parse(v2) + assert.NoError(t, err) + assert.NotNil(t, v2.Pico.Token) + assert.Equal(t, "token1", v2.Pico.Token.String()) + + v3 := &testStruct{Pico: &valueStruct{}} + err = env.Parse(v3) + assert.NoError(t, err) + if v3.Pico.Token != nil { + assert.Equal(t, "token_env", v3.Pico.Token.String()) + } + + type toolsStruct struct { + Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` + } + + type testStruct2 struct { + Tools toolsStruct `json:"tools,omitempty" yaml:",inline"` + } + + v4 := &testStruct2{ + Tools: toolsStruct{ + Pico: valueStruct{ + Url: "https://example.com", + Token: NewSecureString("token1"), + ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, + }, + }, + } + bytes, err = yaml.Marshal(v4) + assert.NoError(t, err) + assert.Equal(t, want, string(bytes)) + jsonBytes, err = json.Marshal(v4) + assert.NoError(t, err) + assert.Equal( + t, + `{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`, + string(jsonBytes), + ) + + v5 := &testStruct2{} + err = json.Unmarshal(jsonBytes, v5) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v5.Tools.Pico.Url) + err = yaml.Unmarshal(bytes, v5) + assert.NoError(t, err) + assert.NotNil(t, v5.Tools.Pico.Token) + assert.Equal(t, "token1", v5.Tools.Pico.Token.raw) + + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + + t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath) + + t.Setenv(credential.PassphraseEnvVar, passphrase) + + v5.Tools.Pico.Token.Set("newtoken1") + v5.Tools.Pico.ApiKeys[0].Set("newapi-key1") + bytes, err = yaml.Marshal(v5) + assert.NoError(t, err) + t.Logf("yaml: %s", string(bytes)) + + v6 := &testStruct2{} + err = yaml.Unmarshal(bytes, v6) + assert.NoError(t, err) + assert.NotNil(t, v6.Tools.Pico.Token) + assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String()) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 75eb458b8..6734257f4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1673,3 +1673,163 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// makeBackup tests +// --------------------------------------------------------------------------- + +// TestMakeBackup_WithDateSuffix verifies backup files include a date suffix. +func TestMakeBackup_WithDateSuffix(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"version":2}`), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + var hasDatedBackup bool + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + hasDatedBackup = true + // Verify backup content matches original + bakPath := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(bakPath) + if err != nil { + t.Fatalf("ReadFile backup: %v", err) + } + if string(data) != `{"version":2}` { + t.Errorf("backup content = %q, want original content", string(data)) + } + break + } + } + if !hasDatedBackup { + t.Error("expected backup file with date suffix pattern config.json.20*.bak") + } +} + +// TestMakeBackup_AlsoBacksSecurityFile verifies that the security config file +// is also backed up with the same date suffix. +func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + secPath := securityPath(configPath) + + os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) + os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600) + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + + configBackups := 0 + secBackups := 0 + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + configBackups++ + } + if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched { + secBackups++ + } + } + if configBackups != 1 { + t.Errorf("expected 1 config backup, got %d", configBackups) + } + if secBackups != 1 { + t.Errorf("expected 1 security backup, got %d", secBackups) + } +} + +// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil +// when the config file does not exist (no error, no panic). +func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "nonexistent.json") + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err) + } +} + +// TestMakeBackup_OnlyConfigNoSecurity verifies backup succeeds when only +// the config file exists and no security file. +func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, _ := os.ReadDir(dir) + configBackups := 0 + secBackups := 0 + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + configBackups++ + } + if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched { + secBackups++ + } + } + if configBackups != 1 { + t.Errorf("expected 1 config backup, got %d", configBackups) + } + if secBackups != 0 { + t.Errorf("expected 0 security backups when no security file exists, got %d", secBackups) + } +} + +// TestMakeBackup_SameDateSuffix verifies that config and security backups +// share the same date suffix (they are created in the same makeBackup call). +func TestMakeBackup_SameDateSuffix(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + secPath := securityPath(configPath) + + os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) + os.WriteFile(secPath, []byte(`key: value`), 0o600) + + if err := makeBackup(configPath); err != nil { + t.Fatalf("makeBackup: %v", err) + } + + entries, _ := os.ReadDir(dir) + var configDate, secDate string + for _, e := range entries { + name := e.Name() + // Extract date part: after the last . before .bak + // e.g. config.json.20260330.bak → 20260330 + if strings.HasPrefix(name, "config.json.") && strings.HasSuffix(name, ".bak") { + configDate = strings.TrimPrefix(name, "config.json.") + configDate = strings.TrimSuffix(configDate, ".bak") + } + if strings.HasPrefix(name, ".security.yml.") && strings.HasSuffix(name, ".bak") { + secDate = strings.TrimPrefix(name, ".security.yml.") + secDate = strings.TrimSuffix(secDate, ".bak") + } + } + if configDate == "" { + t.Fatal("config backup file not found") + } + if secDate == "" { + t.Fatal("security backup file not found") + } + if configDate != secDate { + t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate) + } +} diff --git a/pkg/config/migration.go b/pkg/config/migration.go index fee800a76..7430050b3 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -534,3 +534,26 @@ func loadConfig(data []byte) (*Config, error) { } return cfg, nil } + +func mergeAPIKeys(apiKey string, apiKeys []string) []string { + seen := make(map[string]struct{}) + var all []string + + if k := strings.TrimSpace(apiKey); k != "" { + if _, exists := seen[k]; !exists { + seen[k] = struct{}{} + all = append(all, k) + } + } + + for _, k := range apiKeys { + if trimmed := strings.TrimSpace(k); trimmed != "" { + if _, exists := seen[trimmed]; !exists { + seen[trimmed] = struct{}{} + all = append(all, trimmed) + } + } + } + + return all +} diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go index bc8160967..b180dda90 100644 --- a/pkg/config/migration_integration_test.go +++ b/pkg/config/migration_integration_test.go @@ -681,3 +681,473 @@ web: t.Error("Discord token not preserved in .security.yml file") } } + +// --------------------------------------------------------------------------- +// V1 → V2 migration tests +// --------------------------------------------------------------------------- + +// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys +// are marked as enabled during V1→V2 migration. +func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")}, + {ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")}, + }, + }} + v1.migrateModelEnabled() + for _, m := range v1.ModelList { + if !m.Enabled { + t.Errorf("model %q with API key should be enabled", m.ModelName) + } + } +} + +// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved +// "local-model" entry is enabled even without API keys. +func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"}, + }, + }} + v1.migrateModelEnabled() + if !v1.ModelList[0].Enabled { + t.Error("local-model should be enabled") + } +} + +// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys +// and not named "local-model" remain disabled. +func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4"}, + {ModelName: "claude", Model: "anthropic/claude"}, + }, + }} + v1.migrateModelEnabled() + for _, m := range v1.ModelList { + if m.Enabled { + t.Errorf("model %q without API key should stay disabled", m.ModelName) + } + } +} + +// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with +// explicitly enabled=true is NOT overridden by the migration. +func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true}, + }, + }} + v1.migrateModelEnabled() + if !v1.ModelList[0].Enabled { + t.Error("explicitly enabled model should remain enabled") + } +} + +// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with +// explicitly enabled=false and API keys gets enabled during migration. +// Note: since Go's zero value for bool is false and JSON omitempty omits false, +// migration cannot distinguish "explicitly false" from "field absent". Both cases +// get the same inference treatment. +func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false}, + }, + }} + v1.migrateModelEnabled() + // Even though Enabled was set to false, migration infers it as true because + // the migration cannot distinguish from a missing field (both are zero value). + if !v1.ModelList[0].Enabled { + t.Error("model with API key should be enabled by migration inference") + } +} + +// TestMigrateModelEnabled_Mixed verifies a mix of models. +func TestMigrateModelEnabled_Mixed(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")}, + {ModelName: "no-key", Model: "openai/gpt-4"}, + {ModelName: "local-model", Model: "vllm/custom"}, + { + ModelName: "disabled-explicit", + Model: "openai/gpt-4", + APIKeys: SimpleSecureStrings("sk-test"), + Enabled: false, + }, + }, + }} + v1.migrateModelEnabled() + + assertEnabled := func(name string, want bool) { + for _, m := range v1.ModelList { + if m.ModelName == name { + if m.Enabled != want { + t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want) + } + return + } + } + t.Errorf("model %q not found", name) + } + + assertEnabled("with-key", true) + assertEnabled("no-key", false) + assertEnabled("local-model", true) + assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key +} + +// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration. +func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) { + v1 := &configV1{Config: Config{ + Channels: ChannelsConfig{ + Discord: DiscordConfig{ + MentionOnly: true, + }, + }, + }} + v1.migrateChannelConfigs() + if !v1.Channels.Discord.GroupTrigger.MentionOnly { + t.Error("Discord GroupTrigger.MentionOnly should be set to true") + } +} + +// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test. +func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) { + v1 := &configV1{Config: Config{ + Channels: ChannelsConfig{ + Discord: DiscordConfig{ + GroupTrigger: GroupTriggerConfig{MentionOnly: true}, + }, + }, + }} + v1.migrateChannelConfigs() +} + +// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration. +func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) { + v1 := &configV1{Config: Config{ + Channels: ChannelsConfig{ + OneBot: OneBotConfig{ + GroupTriggerPrefix: []string{"/"}, + }, + }, + }} + v1.migrateChannelConfigs() + if len(v1.Channels.OneBot.GroupTrigger.Prefixes) != 1 || v1.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" { + t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", v1.Channels.OneBot.GroupTrigger.Prefixes) + } +} + +// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations. +func TestMigrateConfigV1_Combined(t *testing.T) { + v1 := &configV1{Config: Config{ + ModelList: []*ModelConfig{ + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")}, + }, + Channels: ChannelsConfig{ + Discord: DiscordConfig{MentionOnly: true}, + }, + }} + result, err := v1.Migrate() + if err != nil { + t.Fatalf("Migrate: %v", err) + } + + if !result.ModelList[0].Enabled { + t.Error("model with API key should be enabled after V1→V2 migration") + } + if !result.Channels.Discord.GroupTrigger.MentionOnly { + t.Error("Discord mention_only should be migrated after V1→V2 migration") + } +} + +// TestLoadConfig_V1ToV2Migration verifies end-to-end V1→V2 config migration +// through LoadConfig, including Enabled field inference and version bump. +func TestLoadConfig_V1ToV2Migration(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Write a V1 config with model_list but no "enabled" field + v1Config := `{ + "version": 1, + "model_list": [ + { + "model_name": "gpt-4", + "model": "openai/gpt-4" + }, + { + "model_name": "local-model", + "model": "vllm/custom-model", + "api_base": "http://localhost:8000/v1" + } + ], + "channels": { + "discord": { + "mention_only": true + } + }, + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + // Version should be bumped to 2 + if cfg.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion) + } + + // gpt-4 has no API key → disabled + gpt4, err := cfg.GetModelConfig("gpt-4") + if err != nil { + t.Fatalf("GetModelConfig(gpt-4): %v", err) + } + if gpt4.Enabled { + t.Error("gpt-4 without API key should be disabled after migration") + } + + // local-model → enabled + local, err := cfg.GetModelConfig("local-model") + if err != nil { + t.Fatalf("GetModelConfig(local-model): %v", err) + } + if !local.Enabled { + t.Error("local-model should be enabled after migration") + } + + // Discord channel config should be migrated + if !cfg.Channels.Discord.GroupTrigger.MentionOnly { + t.Error("Discord mention_only should be migrated to group_trigger.mention_only") + } + + // Verify backup was created with date suffix + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + var hasBackup bool + for _, e := range entries { + if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched { + hasBackup = true + break + } + } + if !hasBackup { + t.Error("expected backup file with date suffix to be created") + } + + // Verify the saved config on disk now has version 2 + saved, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile saved config: %v", err) + } + var versionCheck struct { + Version int `json:"version"` + } + if err := json.Unmarshal(saved, &versionCheck); err != nil { + t.Fatalf("Unmarshal saved config: %v", err) + } + if versionCheck.Version != 2 { + t.Errorf("saved config version = %d, want 2", versionCheck.Version) + } +} + +// TestLoadConfig_V1WithAPIKeysInferredEnabled verifies that V1 configs with +// API keys in the security file get Enabled=true after migration. +func TestLoadConfig_V1WithAPIKeysInferredEnabled(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + secPath := securityPath(configPath) + + v1Config := `{ + "version": 1, + "model_list": [ + {"model_name": "gpt-4", "model": "openai/gpt-4"}, + {"model_name": "claude", "model": "anthropic/claude"} + ], + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + securityConfig := `model_list: + gpt-4:0: + api_keys: + - "sk-gpt-key" + claude:0: + api_keys: + - "sk-claude-key" +` + + if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil { + t.Fatalf("WriteFile security: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + for _, m := range cfg.ModelList { + if !m.Enabled { + t.Errorf("model %q with API key in security file should be enabled", m.ModelName) + } + } +} + +// TestLoadConfig_V2DirectLoad verifies that V2 configs load directly without +// running any migration. +func TestLoadConfig_V2DirectLoad(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + v2Config := `{ + "version": 2, + "model_list": [ + { + "model_name": "gpt-4", + "model": "openai/gpt-4", + "enabled": true + }, + { + "model_name": "claude", + "model": "anthropic/claude" + } + ], + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + if err := os.WriteFile(configPath, []byte(v2Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if cfg.Version != 2 { + t.Errorf("Version = %d, want 2", cfg.Version) + } + + gpt4, _ := cfg.GetModelConfig("gpt-4") + if !gpt4.Enabled { + t.Error("gpt-4 with explicit enabled=true should remain enabled") + } + + claude, _ := cfg.GetModelConfig("claude") + if claude.Enabled { + t.Error("claude without enabled field should be false (no migration for V2)") + } + + // No backup should be created for V2 load + entries, _ := os.ReadDir(tmpDir) + for _, e := range entries { + if matched, _ := filepath.Match("config.json.*.bak", e.Name()); matched { + t.Errorf("V2 load should not create backup, but found %q", e.Name()) + } + } +} + +// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V2 migration produces +// correct Enabled fields and version. +func TestLoadConfig_V0MigrateProducesV2(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + v0Config := `{ + "model_list": [ + { + "model_name": "gpt-4", + "model": "openai/gpt-4", + "api_key": "sk-test" + }, + { + "model_name": "claude", + "model": "anthropic/claude" + }, + { + "model_name": "local-model", + "model": "vllm/custom-model" + } + ], + "gateway": {"host": "127.0.0.1", "port": 18790} + }` + + if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if cfg.Version != CurrentVersion { + t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion) + } + + // Check enabled status + modelEnabled := func(name string) bool { + m, err := cfg.GetModelConfig(name) + if err != nil { + return false + } + return m.Enabled + } + + if !modelEnabled("gpt-4") { + t.Error("gpt-4 with API key from V0 should be enabled") + } + if modelEnabled("claude") { + t.Error("claude without API key from V0 should be disabled") + } + if !modelEnabled("local-model") { + t.Error("local-model from V0 should be enabled") + } +} + +// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error. +func TestLoadConfig_UnsupportedVersion(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}` + if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("LoadConfig should return error for unsupported version") + } + if !containsString(err.Error(), "unsupported config version") { + t.Errorf("error = %q, want 'unsupported config version'", err.Error()) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index e58c6dc9e..947e942da 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -345,7 +345,7 @@ func TestMergeAPIKeys(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := MergeAPIKeys(tt.apiKey, tt.apiKeys) + result := mergeAPIKeys(tt.apiKey, tt.apiKeys) if len(result) != len(tt.expected) { t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result)) } diff --git a/pkg/config/security.go b/pkg/config/security.go index 79dd26e14..2414cd7fa 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -7,20 +7,16 @@ package config import ( "bytes" - "encoding/json" "fmt" "os" "path/filepath" "reflect" - "runtime" "strings" "sync" "gopkg.in/yaml.v3" - "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" - "github.com/sipeed/picoclaw/pkg/logger" ) const ( @@ -66,7 +62,6 @@ func saveSecurityConfig(securityPath string, sec *Config) error { return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600) } -// SensitiveDataCache caches the compiled regex for filtering sensitive data. // SensitiveDataCache caches the strings.Replacer for filtering sensitive data. // Computed once on first access via sync.Once. type SensitiveDataCache struct { @@ -178,234 +173,3 @@ func collectSensitive(v reflect.Value, values *[]string) { } } } - -const ( - notHere = `"[NOT_HERE]"` -) - -// SecureStrings is a slice of SecureString -type SecureStrings []*SecureString - -// Values returns the decrypted/resolved values -func (s *SecureStrings) Values() []string { - if s == nil { - return nil - } - keys := make([]string, len(*s)) - for i, k := range *s { - keys[i] = k.String() - } - return unique(keys) -} - -func SimpleSecureStrings(val ...string) SecureStrings { - val = unique(val) - vv := make(SecureStrings, len(val)) - for i, s := range val { - vv[i] = NewSecureString(s) - } - return vv -} - -// unique returns a new slice with duplicate elements removed. -func unique[T comparable](input []T) []T { - m := make(map[T]struct{}) - var result []T - for _, v := range input { - if _, ok := m[v]; !ok { - m[v] = struct{}{} - result = append(result, v) - } - } - return result -} - -func (s SecureStrings) MarshalJSON() ([]byte, error) { - return []byte(notHere), nil -} - -func (s *SecureStrings) UnmarshalJSON(value []byte) error { - if string(value) == notHere { - return nil - } - var v []*SecureString - err := json.Unmarshal(value, &v) - if err != nil { - return err - } - *s = v - return nil -} - -// SecureString the string value that can be decrypted or resolved -// -//nolint:recvcheck -type SecureString struct { - resolved string // Decrypted/resolved value returned by String() - raw string // Persisted raw value (enc://, file://, or plaintext) -} - -func callerFromYaml() bool { - _, file, _, ok := runtime.Caller(2) - if ok { - d := filepath.Dir(file) - // check the caller is from yaml.v - if !strings.Contains(d, "yaml.v") { - return true - } - } - return false -} - -// IsZero returns true if the SecureString is empty -// if caller not yaml, just return true for prevent marshal this field -func (s SecureString) IsZero() bool { - if callerFromYaml() { - return true - } - return s.resolved == "" -} - -func NewSecureString(value string) *SecureString { - s := &SecureString{} - if err := s.fromRaw(value); err != nil { - logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err)) - } - return s -} - -func (s *SecureString) String() string { - if s == nil { - return "" - } - return s.resolved -} - -func (s *SecureString) Set(value string) *SecureString { - s.resolved = value - s.raw = "" - return s -} - -func (s SecureString) MarshalJSON() ([]byte, error) { - return []byte(notHere), nil -} - -func (s *SecureString) UnmarshalJSON(value []byte) error { - if string(value) == notHere { - return nil - } - var v string - if err := json.Unmarshal(value, &v); err != nil { - return err - } - return s.fromRaw(v) -} - -func (s SecureString) MarshalYAML() (any, error) { - // Preserve raw value if it is already a reference (enc:// or file://) - if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { - return s.raw, nil - } - // If resolved is a reference format (e.g. set via Set), copy back to raw - if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) { - s.raw = s.resolved - return s.raw, nil - } - // Try to encrypt the resolved value - if passphrase := credential.PassphraseProvider(); passphrase != "" { - encrypted, err := credential.Encrypt(passphrase, "", s.resolved) - if err != nil { - logger.Errorf("Encrypt error: %v", err) - return nil, err - } - s.raw = encrypted - } else { - s.raw = s.resolved - } - return s.raw, nil -} - -func (s *SecureString) UnmarshalYAML(value *yaml.Node) error { - return s.fromRaw(value.Value) -} - -func (s *SecureString) fromRaw(v string) error { - s.raw = v - vv, err := resolveKey(v) - if err != nil { - return err - } - s.resolved = vv - return nil -} - -var ( - secResolverMu sync.RWMutex - secResolver *credential.Resolver -) - -func updateResolver(path string) { - secResolverMu.Lock() - defer secResolverMu.Unlock() - secResolver = credential.NewResolver(path) -} - -func resolveKey(v string) (string, error) { - secResolverMu.RLock() - resolver := secResolver - secResolverMu.RUnlock() - if resolver == nil { - resolver = credential.NewResolver("") - } - if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") { - decrypted, err := resolver.Resolve(v) - if err != nil { - logger.Errorf("Resolve error: %v", err) - return "", err - } - return decrypted, nil - } - return v, nil -} - -func (s *SecureString) UnmarshalText(text []byte) error { - v := string(text) - return s.fromRaw(v) -} - -type SecureModelList []*ModelConfig - -func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error { - mm := make(map[string]*ModelConfig) - if err := value.Decode(&mm); err != nil { - logger.Errorf("Decode error: %v", err) - return err - } - nameList := toNameIndex(*v) - for i, m := range *v { - sec := mm[nameList[i]] - if sec == nil { - sec = mm[m.ModelName] - } - if sec != nil { - m.APIKeys = sec.APIKeys - } - } - return nil -} - -func (v SecureModelList) MarshalYAML() (any, error) { - type onlySecureData struct { - APIKeys SecureStrings `yaml:"api_keys,omitempty"` - } - mm := make(map[string]onlySecureData) - nameList := toNameIndex(v) - for i, m := range v { - mm[nameList[i]] = onlySecureData{ - APIKeys: m.APIKeys, - } - } - - return mm, nil -} diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go index 834ba3606..548a6dc87 100644 --- a/pkg/config/security_test.go +++ b/pkg/config/security_test.go @@ -15,8 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" - - "github.com/sipeed/picoclaw/pkg/credential" ) func TestSecurityConfig(t *testing.T) { @@ -227,134 +225,3 @@ skills: assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw) }) } - -func TestLoadSecurityValue(t *testing.T) { - type valueStruct struct { - Url string `json:"url,omitempty" yaml:"-"` - Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"` - ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"` - } - - type testStruct struct { - Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` - } - - v1 := &testStruct{ - Pico: &valueStruct{ - Url: "https://example.com", - Token: NewSecureString("token1"), - ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, - }, - } - bytes, err := yaml.Marshal(v1) - assert.NoError(t, err) - jsonBytes, err := json.Marshal(v1) - assert.NoError(t, err) - const want = `pico: - token: token1 - api_keys: - - api-key1 - - api-key2 -` - const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}` - v0 := &testStruct{} - err = json.Unmarshal([]byte(jsonPost), v0) - assert.NoError(t, err) - assert.Equal(t, "https://example.com", v0.Pico.Url) - assert.Equal(t, "token0", v0.Pico.Token.String()) - - const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}` - assert.Equal(t, want, string(bytes)) - assert.Equal(t, jsonWant, string(jsonBytes)) - - v2 := &testStruct{} - err = json.Unmarshal(jsonBytes, v2) - assert.NoError(t, err) - err = yaml.Unmarshal(bytes, v2) - assert.NoError(t, err) - assert.Equal(t, "https://example.com", v2.Pico.Url) - if v2.Pico.Token != nil { - assert.Equal(t, "token1", v2.Pico.Token.String()) - assert.Equal(t, "token1", v2.Pico.Token.raw) - } - - v2.Pico.Token = NewSecureString("token1") - v2.Pico.Token.raw = "abc" - err = yaml.Unmarshal(bytes, v2) - assert.NoError(t, err) - assert.Equal(t, "token1", v2.Pico.Token.raw) - - os.Setenv("PICO_TOKEN", "token_env") - err = env.Parse(v2) - assert.NoError(t, err) - assert.NotNil(t, v2.Pico.Token) - assert.Equal(t, "token1", v2.Pico.Token.String()) - - v3 := &testStruct{Pico: &valueStruct{}} - err = env.Parse(v3) - assert.NoError(t, err) - if v3.Pico.Token != nil { - assert.Equal(t, "token_env", v3.Pico.Token.String()) - } - - type toolsStruct struct { - Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` - } - - type testStruct2 struct { - Tools toolsStruct `json:"tools,omitempty" yaml:",inline"` - } - - v4 := &testStruct2{ - Tools: toolsStruct{ - Pico: valueStruct{ - Url: "https://example.com", - Token: NewSecureString("token1"), - ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, - }, - }, - } - bytes, err = yaml.Marshal(v4) - assert.NoError(t, err) - assert.Equal(t, want, string(bytes)) - jsonBytes, err = json.Marshal(v4) - assert.NoError(t, err) - assert.Equal( - t, - `{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`, - string(jsonBytes), - ) - - v5 := &testStruct2{} - err = json.Unmarshal(jsonBytes, v5) - assert.NoError(t, err) - assert.Equal(t, "https://example.com", v5.Tools.Pico.Url) - err = yaml.Unmarshal(bytes, v5) - assert.NoError(t, err) - assert.NotNil(t, v5.Tools.Pico.Token) - assert.Equal(t, "token1", v5.Tools.Pico.Token.raw) - - dir := t.TempDir() - sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") - if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } - - const passphrase = "test-passphrase-32bytes-long-ok!" - - t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath) - - t.Setenv(credential.PassphraseEnvVar, passphrase) - - v5.Tools.Pico.Token.Set("newtoken1") - v5.Tools.Pico.ApiKeys[0].Set("newapi-key1") - bytes, err = yaml.Marshal(v5) - assert.NoError(t, err) - t.Logf("yaml: %s", string(bytes)) - - v6 := &testStruct2{} - err = yaml.Unmarshal(bytes, v6) - assert.NoError(t, err) - assert.NotNil(t, v6.Tools.Pico.Token) - assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String()) -} diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 38a55948b..fd3cd85b7 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -40,6 +40,7 @@ type modelResponse struct { ThinkingLevel string `json:"thinking_level,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` // Meta + Enabled bool `json:"enabled"` Configured bool `json:"configured"` IsDefault bool `json:"is_default"` IsVirtual bool `json:"is_virtual"` @@ -85,6 +86,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, + Enabled: m.Enabled, Configured: configured[i], IsDefault: m.ModelName == defaultModel, IsVirtual: m.IsVirtual(), From b67d3cfbd86af93a928f717208a69442211ee217 Mon Sep 17 00:00:00 2001 From: BeaconCat <111232138+BeaconCat@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:44:32 +0800 Subject: [PATCH 27/72] docs: document gateway.log_level in all READMEs and i18n configuration docs (#2178) * docs: document gateway.log_level in all READMEs and i18n configuration docs Add gateway log level note to Channels section in all 9 READMEs and add Gateway Log Level section to zh/fr/ja/pt-br/vi configuration docs. - gateway.log_level (default: fatal) controls log verbosity - Supported values: debug, info, warn, error, fatal - Can also be set via PICOCLAW_LOG_LEVEL env var - English docs/configuration.md already had this section * fix(docs): correct gateway.log_level default from fatal to warn DefaultConfig() sets Gateway.LogLevel to "warn", not "fatal". Update all READMEs and i18n configuration docs to reflect the actual default value. --------- Co-authored-by: BeaconCat --- README.fr.md | 2 ++ README.id.md | 2 ++ README.it.md | 2 ++ README.ja.md | 2 ++ README.md | 2 ++ README.my.md | 2 ++ README.pt-br.md | 2 ++ README.vi.md | 2 ++ README.zh.md | 2 ++ docs/configuration.md | 4 ++-- docs/fr/configuration.md | 16 ++++++++++++++++ docs/ja/configuration.md | 16 ++++++++++++++++ docs/pt-br/configuration.md | 16 ++++++++++++++++ docs/vi/configuration.md | 16 ++++++++++++++++ docs/zh/configuration.md | 16 ++++++++++++++++ 15 files changed, 100 insertions(+), 2 deletions(-) diff --git a/README.fr.md b/README.fr.md index 4fd326102..8a035f9b3 100644 --- a/README.fr.md +++ b/README.fr.md @@ -462,6 +462,8 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie : > Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé. +> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails. + Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md). ## 🔧 Outils diff --git a/README.id.md b/README.id.md index d88f5eb32..3fe4c1276 100644 --- a/README.id.md +++ b/README.id.md @@ -458,6 +458,8 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan: > Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama. +> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail. + Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md). ## 🔧 Tools diff --git a/README.it.md b/README.it.md index 5874ca27a..8748aea9c 100644 --- a/README.it.md +++ b/README.it.md @@ -458,6 +458,8 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica: > Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso. +> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli. + Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md). ## 🔧 Strumenti diff --git a/README.ja.md b/README.ja.md index 2f17516af..3772ff532 100644 --- a/README.ja.md +++ b/README.ja.md @@ -458,6 +458,8 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m > webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。 +> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。 + Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。 ## 🔧 ツール diff --git a/README.md b/README.md index e963fb1cf..9441747ca 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,8 @@ Talk to your PicoClaw through 17+ messaging platforms: > All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server. +> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details. + For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md). ## 🔧 Tools diff --git a/README.my.md b/README.my.md index 2f1a38942..c07cdd005 100644 --- a/README.my.md +++ b/README.my.md @@ -458,6 +458,8 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan: > Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi. +> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran. + Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md). ## 🔧 Alat diff --git a/README.pt-br.md b/README.pt-br.md index 42ce5cc95..dfe7cb0f2 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -458,6 +458,8 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens: > Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado. +> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes. + Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md). ## 🔧 Ferramentas diff --git a/README.vi.md b/README.vi.md index 5ca39d99d..6c8f6ad44 100644 --- a/README.vi.md +++ b/README.vi.md @@ -458,6 +458,8 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin: > Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung. +> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết. + Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md). ## 🔧 Tools diff --git a/README.zh.md b/README.zh.md index 7ca20b1ed..b92e8e889 100644 --- a/README.zh.md +++ b/README.zh.md @@ -458,6 +458,8 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模 > 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。 +> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。 + 详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。 ## 🔧 Tools diff --git a/docs/configuration.md b/docs/configuration.md index 3462767e6..9b9524692 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,12 +38,12 @@ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gat ```json { "gateway": { - "log_level": "fatal" + "log_level": "warn" } } ``` -When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`. +When omitted, the default is `warn`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`. You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`. diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md index 8d94620ba..fd8602c68 100644 --- a/docs/fr/configuration.md +++ b/docs/fr/configuration.md @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` +### Niveau de Log du Gateway + +`gateway.log_level` contrôle la verbosité des logs du Gateway, configurable dans `config.json` : + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +La valeur par défaut est `warn`. Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. + +Peut également être surchargé via la variable d'environnement : `PICOCLAW_LOG_LEVEL=info` + ### Structure du Workspace PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md index 35676809e..da60b6052 100644 --- a/docs/ja/configuration.md +++ b/docs/ja/configuration.md @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` +### Gateway ログレベル + +`gateway.log_level` は Gateway のログ詳細度を制御します。`config.json` で設定できます: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +デフォルト値は `warn` です。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。 + +環境変数でも上書き可能です:`PICOCLAW_LOG_LEVEL=info` + ### ワークスペースレイアウト PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md index ff3ce2b34..cb836ce0f 100644 --- a/docs/pt-br/configuration.md +++ b/docs/pt-br/configuration.md @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` +### Nível de Log do Gateway + +`gateway.log_level` controla a verbosidade dos logs do Gateway, configurável em `config.json`: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +O valor padrão é `warn`. Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. + +Também pode ser substituído pela variável de ambiente: `PICOCLAW_LOG_LEVEL=info` + ### Layout do Workspace O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`): diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md index fecadc6ff..b75215c3f 100644 --- a/docs/vi/configuration.md +++ b/docs/vi/configuration.md @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` +### Mức Log của Gateway + +`gateway.log_level` kiểm soát mức độ chi tiết của log Gateway, có thể cấu hình trong `config.json`: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +Giá trị mặc định là `warn`. Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. + +Cũng có thể ghi đè bằng biến môi trường: `PICOCLAW_LOG_LEVEL=info` + ### Bố Cục Workspace PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 3b0ac9a50..c27a439f0 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway ``` +### Gateway 日志等级 + +`gateway.log_level` 控制 Gateway 的日志详细程度,可在 `config.json` 中配置: + +```json +{ + "gateway": { + "log_level": "warn" + } +} +``` + +默认值为 `warn`。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。 + +也可通过环境变量覆盖:`PICOCLAW_LOG_LEVEL=info` + ### 工作区布局 (Workspace Layout) PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): From edda02ce67cc5ef5124351ca86d1c3b522835cca Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 30 Mar 2026 14:45:52 +0800 Subject: [PATCH 28/72] build(web): refactor launcher build flow and expand WebUI documentation (#2174) - delegate root launcher builds to the web Makefile - add dedicated frontend and dev picoclaw build targets - document the WebUI architecture, runtime behavior, and build workflow --- Makefile | 13 +- web/Makefile | 64 ++++++--- web/README.md | 374 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 406 insertions(+), 45 deletions(-) diff --git a/Makefile b/Makefile index 9581fa633..992182775 100644 --- a/Makefile +++ b/Makefile @@ -130,14 +130,17 @@ build: generate build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @if [ ! -f web/backend/dist/index.html ]; then \ - echo "Building frontend..."; \ - cd web/frontend && pnpm install && pnpm build:backend; \ - fi - @$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend + @$(MAKE) -C web build \ + OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \ + WEB_GO='$(WEB_GO)' \ + GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ + LDFLAGS='$(LDFLAGS)' @ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher" +build-launcher-frontend: + @$(MAKE) -C web build-frontend + ## build-launcher-tui: Build the picoclaw-launcher TUI binary build-launcher-tui: @echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..." diff --git a/web/Makefile b/web/Makefile index 06717f2b9..891c170c2 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,12 +1,20 @@ -.PHONY: dev dev-frontend dev-backend build test lint clean +.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean # Go variables GO?=CGO_ENABLED=0 go WEB_GO?=$(GO) -GOFLAGS?=-v -tags stdjson +GO_BUILD_TAGS?=goolm,stdjson +GOFLAGS?=-v -tags $(GO_BUILD_TAGS) # Build variables BUILD_DIR=build +OUTPUT?=$(BUILD_DIR)/picoclaw-launcher +FRONTEND_DIR=frontend +BACKEND_DIR=backend +BACKEND_DIST=$(BACKEND_DIR)/dist +PICOCLAW_BINARY_NAME=picoclaw +PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME)) +LAUNCHER_GUI_LDFLAG= # Version VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") @@ -52,45 +60,63 @@ else ifeq ($(UNAME_S),Darwin) else ifeq ($(UNAME_S),Windows) PLATFORM=windows ARCH=$(UNAME_M) - LDFLAGS=-H=windowsgui $(LDFLAGS) + PICOCLAW_BINARY_NAME=picoclaw.exe + LAUNCHER_GUI_LDFLAG=-H=windowsgui else PLATFORM=$(UNAME_S) ARCH=$(UNAME_M) endif +LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS)) + # Run both frontend and backend dev servers -dev: - @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \ - echo "Build artifacts not found, building..."; \ - $(MAKE) build; \ +dev: build-dev-picoclaw + @if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \ + echo "Embedded frontend not found, building..."; \ + $(MAKE) build-frontend; \ fi @echo "Starting backend and frontend dev servers..." - @$(MAKE) dev-backend & $(MAKE) dev-frontend + @$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend # Start frontend dev server (Vite, with proxy to backend) dev-frontend: - cd frontend && pnpm dev + cd $(FRONTEND_DIR) && pnpm dev # Start backend dev server dev-backend: - cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" . + cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS) # Build frontend and embed into Go binary -build: - cd frontend && pnpm build:backend - ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/ +build: build-frontend + @mkdir -p "$$(dirname "$(OUTPUT)")" + ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/ + +build-frontend: + @if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ + [ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \ + [ $(FRONTEND_DIR)/pnpm-lock.yaml -nt $(FRONTEND_DIR)/node_modules ]; then \ + echo "Installing frontend dependencies..."; \ + cd $(FRONTEND_DIR) && pnpm install --frozen-lockfile; \ + fi + @echo "Building frontend..." + @cd $(FRONTEND_DIR) && pnpm build:backend + +build-dev-picoclaw: + @echo "Building picoclaw for launcher development..." + @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")" + @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw # Run all tests test: - cd backend && ${WEB_GO} test ./... - cd frontend && pnpm lint + cd $(BACKEND_DIR) && ${WEB_GO} test ./... + cd $(FRONTEND_DIR) && pnpm lint # Lint and format lint: - cd backend && ${WEB_GO} vet ./... - cd frontend && pnpm check + cd $(BACKEND_DIR) && ${WEB_GO} vet ./... + cd $(FRONTEND_DIR) && pnpm check # Clean build artifacts clean: - rm -rf frontend/dist backend/dist $(BUILD_DIR) - mkdir -p backend/dist && touch backend/dist/.gitkeep + rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR) + node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs diff --git a/web/README.md b/web/README.md index 6ec247bae..a3faa03d1 100644 --- a/web/README.md +++ b/web/README.md @@ -1,51 +1,383 @@ -# Picoclaw Web +# PicoClaw Web -This directory contains the standalone web service for `picoclaw`. -It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine. +`web/` contains the standalone WebUI launcher for PicoClaw. +It is not just a frontend: it is a small launcher service that bundles a React dashboard, exposes a backend API, manages launcher authentication, and starts or attaches to the `picoclaw gateway` process. + +![PicoClaw Launcher](./picoclaw-launcher.png) + +## What This Directory Provides + +- A browser-based chat UI backed by the Pico channel WebSocket proxy. +- A dashboard for models, credentials, channels, agent tools, skills, logs, and runtime settings. +- A launcher process that can auto-open the browser, show a system tray menu, and persist launcher-specific settings. +- A controlled way to start, stop, restart, and inspect the `picoclaw gateway` subprocess. +- A single-binary deployment target where the frontend is embedded into the Go backend. ## Architecture -The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment. +This directory is a small monorepo: -* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable. -* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface. +- `backend/` + - Go HTTP server and launcher runtime. + - Serves REST APIs, authentication endpoints, channel helper flows, and the Pico WebSocket reverse proxy. + - Embeds compiled frontend assets from `backend/dist`. +- `frontend/` + - Vite + React 19 + TanStack Router SPA. + - Provides the launcher dashboard and chat UI. -## Getting Started +At runtime the launcher and the main PicoClaw engine are separate processes: + +1. The launcher starts the web backend on port `18800` by default. +2. The launcher serves the dashboard and handles dashboard authentication. +3. When allowed, it starts or attaches to `picoclaw gateway -E`. +4. The frontend talks only to the launcher backend. +5. The launcher proxies chat traffic to the gateway through `/pico/ws`. + +## Dashboard Capabilities + +The current frontend exposes these major pages and flows: + +- `/` + - Chat UI with session history, default model selection, and Pico channel messaging. +- `/models` + - Add, edit, delete, and set the default model. + - Supports API-key models, OAuth-backed models, and local/CLI-backed models. +- `/credentials` + - Manage provider credentials. + - Current built-in flows: OpenAI, Anthropic, and Google Antigravity. +- `/channels/*` + - Configure supported channels from a shared catalog. + - Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`. + - Includes QR-based binding helpers for WeChat and WeCom. +- `/agent/skills` + - Browse built-in, global, and workspace skills. + - Import Markdown skills into the workspace and delete workspace-owned skills. +- `/agent/tools` + - View tool availability and enable or disable tool switches through config-backed APIs. +- `/config` + - Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings. +- `/logs` + - View the in-memory gateway log buffer and clear it. + +The UI currently supports English and Simplified Chinese, plus light and dark themes. + +## Runtime Behavior + +### Config Resolution + +The launcher uses the same PicoClaw config file as the main binary. + +- Default app config path: `~/.picoclaw/config.json` +- Override with environment variable: `PICOCLAW_CONFIG` +- Override with a positional CLI argument: `picoclaw-launcher /path/to/config.json` + +Launcher-only settings are stored beside that app config: + +- File name: `launcher-config.json` +- Default location: `~/.picoclaw/launcher-config.json` + +That file currently stores: + +- `port` +- `public` +- `allowed_cidrs` + +If `-port` or `-public` are passed explicitly, the CLI flag wins for that run. +If they are omitted, stored launcher settings are used. + +### First-Run Onboarding + +If the target config file does not exist, the launcher tries to bootstrap it automatically by running: + +```bash +picoclaw onboard +``` + +The launcher looks for the main PicoClaw binary in this order: + +1. `PICOCLAW_BINARY` +2. A `picoclaw` binary in the same directory as the launcher +3. `picoclaw` from `PATH` + +If onboarding or gateway startup cannot find the main binary, set `PICOCLAW_BINARY` explicitly. + +### Gateway Management + +The launcher manages `picoclaw gateway -E`. + +On startup it tries to auto-start or attach to the gateway, but only when startup preconditions pass. In the current code, the main checks are: + +- a default model is configured +- the default model entry is valid +- the default model has usable credentials +- local/runtime-probed models are reachable + +When a gateway process is started by the launcher, the launcher: + +- captures stdout and stderr into an in-memory ring buffer +- tracks transient states such as `starting`, `restarting`, and `stopping` +- marks restart-required when the default model or enabled tool set changed since boot +- ensures the Pico channel is configured before startup + +### Launcher Authentication + +The dashboard is protected by a launcher access token. + +- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used. +- Otherwise a random token is generated for each launcher process. +- The browser auto-open URL includes `?token=...` so local launches can sign in automatically. +- Manual login uses `/launcher-login`. +- API clients may also authenticate with `Authorization: Bearer `. + +Where users can retrieve the token depends on launch mode: + +- Console mode: printed to stdout +- GUI mode: available through the tray menu on supported builds +- GUI mode without stdout: + - random per-run tokens are written to the launcher log + - default log path: `~/.picoclaw/logs/launcher.log` + - if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log` + - env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use + +### Network Exposure + +By default the launcher listens on: + +```text +127.0.0.1:18800 +``` + +With `-public` or `public: true`, it listens on all interfaces: + +```text +0.0.0.0:18800 +``` + +When public access is enabled: + +- the launcher can still protect the dashboard with the access token +- optional `allowed_cidrs` can restrict which client IP ranges may connect +- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths + +## Build And Run ### Prerequisites -* Go 1.25+ -* Node.js 20+ with pnpm +- Go `1.25+` +- Node.js `20+` +- `pnpm` -### Development +On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected. +On Darwin or FreeBSD without cgo, the launcher falls back to headless mode without a tray. -Run both the frontend dev server and the Go backend simultaneously: +If you want to prepare the frontend workspace manually, you can still install dependencies yourself: + +```bash +cd frontend +pnpm install +``` + +### Recommended Development Workflow + +From the `web/` directory: ```bash make dev ``` -Or run them separately: +This does three things: + +1. Builds `../build/picoclaw` for launcher development. +2. Starts the Go backend with `PICOCLAW_BINARY` pointing at that binary. +3. Starts the Vite frontend dev server. + +Use this when you want the full launcher flow during development. + +### Run Frontend And Backend Separately ```bash -make dev-frontend # Vite dev server -make dev-backend # Go backend +make dev-frontend +make dev-backend ``` -### Build +Notes: -Build the frontend and embed it into a single Go binary: +- `dev-frontend` runs the Vite server. +- `dev-backend` runs the Go backend only. +- The Vite dev server proxies `/api` to `http://localhost:18800`. +- Chat WebSocket URLs are generated by the backend, so the frontend does not hardcode gateway addresses. +- Running `dev-backend` alone is mainly useful for backend work or when `backend/dist` already contains a built frontend. + +### Build The Standalone Launcher Binary + +From `web/`: ```bash make build ``` -The output binary is `backend/picoclaw-web`. +This: -### Other Commands +1. Installs frontend dependencies when needed. +2. Builds the frontend into `backend/dist`. +3. Embeds those assets into the Go backend. +4. Produces `build/picoclaw-launcher`. + +Override the output path if needed: ```bash -make test # Run backend tests and frontend lint -make lint # Run go vet and prettier/eslint -make clean # Remove all build artifacts +make build OUTPUT=/tmp/picoclaw-launcher ``` + +From the repository root you can also use: + +```bash +make build-launcher +``` + +That writes the platform-specific launcher to: + +```text +build/picoclaw-launcher-- +``` + +and refreshes the `build/picoclaw-launcher` symlink. + +### Frontend-Only Builds + +For frontend work there are two useful package scripts: + +```bash +cd frontend +pnpm build +pnpm build:backend +``` + +- `pnpm build` writes a normal Vite build to `frontend/dist` +- `pnpm build:backend` writes the embeddable build to `../backend/dist` + +### Run The Built Launcher + +Examples: + +```bash +./build/picoclaw-launcher +./build/picoclaw-launcher -console +./build/picoclaw-launcher -public +./build/picoclaw-launcher -port 19999 /path/to/config.json +``` + +Current launcher flags: + +- `-port` +- `-public` +- `-no-browser` +- `-lang` +- `-console` + +## Make Targets + +From `web/`: + +```bash +make dev +make dev-frontend +make dev-backend +make build +make build-frontend +make test +make lint +make clean +``` + +What they do today: + +- `make build-frontend` + - Runs `pnpm install --frozen-lockfile` when dependencies are missing or stale. + - Builds the embeddable frontend into `backend/dist`. +- `make test` + - Runs backend Go tests. + - Runs frontend `pnpm lint`. +- `make lint` + - Runs backend `go vet`. + - Runs frontend `pnpm check`. + - `pnpm check` currently formats files with Prettier and fixes lint issues with ESLint, so this target can modify your working tree. +- `make clean` + - Removes `frontend/dist`, `backend/dist`, and `build/`, then recreates `backend/dist/.gitkeep`. + +## Directory Layout + +```text +web/ +├── backend/ +│ ├── api/ # REST API handlers and launcher runtime endpoints +│ ├── launcherconfig/ # launcher-config.json load/save/validation +│ ├── middleware/ # auth, content type, logging, CIDR allowlist +│ ├── model/ # Go data structures and logic wrappers +│ ├── utils/ # runtime helpers, onboarding, browser launch +│ ├── winres/ # Windows application resources +│ └── dist/ # embedded frontend build output +├── frontend/ +│ ├── src/api/ # browser API clients +│ ├── src/components/ # UI pages and shared components +│ ├── src/features/ # feature-specific state, controllers, and protocol helpers +│ ├── src/hooks/ # shared React hooks +│ ├── src/i18n/ # internationalization language packs +│ ├── src/lib/ # generic library utilities +│ ├── src/routes/ # TanStack file routes +│ ├── src/store/ # global state management +│ └── vite.config.ts # dev server and build config +├── Makefile +└── README.md +``` + +## Troubleshooting + +### You have to sign in again after the launcher restarts + +Existing dashboard sessions do not survive launcher restarts. +That is expected: each launcher process generates a new signed session value, so old cookies become invalid. + +To make re-login easier, set a stable token: + +```bash +export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token" +``` + +Notes: + +- a stable token does not preserve the old cookie-based session by itself +- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically +- if you reopen the dashboard manually, use the same stable token on `/launcher-login` + +### "Start Gateway" stays disabled + +The launcher only allows gateway startup when the configured default model is usable. +Check these in the dashboard: + +- a default model is selected +- the model has credentials or OAuth state +- local models such as Ollama or vLLM are reachable + +### The launcher cannot find `picoclaw` + +Set the main binary explicitly: + +```bash +export PICOCLAW_BINARY=/absolute/path/to/picoclaw +``` + +This affects onboarding and gateway subprocess startup. + +### The backend starts but the UI is blank in development + +Use `make dev` for the normal workflow. +If you run only `make dev-backend`, either run `make dev-frontend` alongside it or build the embedded frontend first with `make build-frontend`. + +## Related Docs + +- Main project overview: [`../README.md`](../README.md) +- Configuration guide: [`../docs/configuration.md`](../docs/configuration.md) +- Providers: [`../docs/providers.md`](../docs/providers.md) +- Troubleshooting: [`../docs/troubleshooting.md`](../docs/troubleshooting.md) +- Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io) From f07a8a89d5ea40d68f977cef10b6ab995c422d2c Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 30 Mar 2026 15:29:43 +0800 Subject: [PATCH 29/72] chore(web): patch vulnerable frontend tooling dependencies (#2182) - upgrade Vite, ESLint, React plugin, and related frontend packages to secure versions - refresh the pnpm lockfile to pull in patched transitive dependencies - raise the required Node.js version to match the patched toolchain - update the web README with the new frontend runtime requirement --- web/README.md | 2 +- web/frontend/package.json | 11 +- web/frontend/pnpm-lock.yaml | 1157 +++++++++++++++-------------------- 3 files changed, 510 insertions(+), 660 deletions(-) diff --git a/web/README.md b/web/README.md index a3faa03d1..9fc7007e9 100644 --- a/web/README.md +++ b/web/README.md @@ -164,7 +164,7 @@ When public access is enabled: ### Prerequisites - Go `1.25+` -- Node.js `20+` +- Node.js 20.19+ or 22.13+ - `pnpm` On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected. diff --git a/web/frontend/package.json b/web/frontend/package.json index 8053d1f2a..fc993451e 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -42,7 +45,7 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", @@ -50,8 +53,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.57.1", - "@vitejs/plugin-react": "^5.2.0", - "eslint": "^9.39.4", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", @@ -60,6 +63,6 @@ "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.1", - "vite": "^7.3.1" + "vite": "^8.0.3" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index edaf49ccc..36217d0ef 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -13,19 +13,19 @@ importers: version: 5.2.8 '@tabler/icons-react': specifier: ^3.40.0 - version: 3.40.0(react@19.2.4) + version: 3.41.1(react@19.2.4) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) '@tanstack/react-query': specifier: ^5.90.21 - version: 5.91.2(react@19.2.4) + version: 5.95.2(react@19.2.4) '@tanstack/react-router': specifier: ^1.167.0 - version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': specifier: ^1.163.3 - version: 1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -37,13 +37,13 @@ importers: version: 1.11.20 i18next: specifier: ^25.8.14 - version: 25.8.20(typescript@5.9.3) + version: 25.10.10(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 jotai: specifier: ^2.18.1 - version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + version: 2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -55,7 +55,7 @@ importers: version: 19.2.4(react@19.2.4) react-i18next: specifier: ^16.5.8 - version: 16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -73,7 +73,7 @@ importers: version: 4.0.1 shadcn: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(typescript@5.9.3) + version: 4.1.1(@types/node@25.5.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -91,14 +91,14 @@ importers: version: 10.0.0 devDependencies: '@eslint/js': - specifier: ^9.39.4 - version: 9.39.4 + specifier: ^10.0.1 + version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + version: 1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.1) @@ -113,22 +113,22 @@ importers: version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': specifier: ^8.57.1 - version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': - specifier: ^5.2.0 - version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) eslint: - specifier: ^9.39.4 - version: 9.39.4(jiti@2.6.1) + specifier: ^10.1.0 + version: 10.1.0(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + version: 7.0.1(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-react-refresh: specifier: ^0.4.26 - version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + version: 0.4.26(eslint@10.1.0(jiti@2.6.1)) globals: specifier: ^16.5.0 version: 16.5.0 @@ -143,10 +143,10 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.57.1 - version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + specifier: ^8.0.3 + version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) packages: @@ -255,18 +255,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.28.6': resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} @@ -295,8 +283,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@dotenvx/dotenvx@1.57.0': - resolution: {integrity: sha512-WsTEcqfHzKmLFZh3jLGd7o4iCkrIupp+qFH2FJUJtQXUh2GcOnLXD00DcrhlO4H8QSmaKnW9lugOEbrdpu25kA==} + '@dotenvx/dotenvx@1.59.1': + resolution: {integrity: sha512-Qg+meC+XFxliuVSDlEPkKnaUjdaJKK6FNx/Wwl2UxhQR8pyPIuLhMavsF7ePdB9qFZUWV1jEK3ckbJir/WmF4w==} hasBin: true '@ecies/ciphers@0.2.5': @@ -305,6 +293,15 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} @@ -471,33 +468,34 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -590,8 +588,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.28.0': + resolution: {integrity: sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -604,6 +602,12 @@ packages: resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -637,6 +641,9 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1327,133 +1334,100 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1462,13 +1436,13 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@tabler/icons-react@3.40.0': - resolution: {integrity: sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==} + '@tabler/icons-react@3.41.1': + resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==} peerDependencies: react: '>= 16' - '@tabler/icons@3.40.0': - resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==} + '@tabler/icons@3.41.1': + resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -1569,65 +1543,65 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.91.2': - resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==} + '@tanstack/query-core@5.95.2': + resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} - '@tanstack/react-query@5.91.2': - resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==} + '@tanstack/react-query@5.95.2': + resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.9': - resolution: {integrity: sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg==} + '@tanstack/react-router-devtools@1.166.11': + resolution: {integrity: sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.167.2 - '@tanstack/router-core': ^1.167.2 + '@tanstack/react-router': ^1.168.2 + '@tanstack/router-core': ^1.168.2 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.167.5': - resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==} + '@tanstack/react-router@1.168.8': + resolution: {integrity: sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.9.2': - resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.167.5': - resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==} + '@tanstack/router-core@1.168.7': + resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==} engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-devtools-core@1.166.9': - resolution: {integrity: sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw==} + '@tanstack/router-devtools-core@1.167.1': + resolution: {integrity: sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.167.2 + '@tanstack/router-core': ^1.168.2 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.166.13': - resolution: {integrity: sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg==} + '@tanstack/router-generator@1.166.22': + resolution: {integrity: sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.166.14': - resolution: {integrity: sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ==} + '@tanstack/router-plugin@1.167.9': + resolution: {integrity: sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA==} engines: {node: '>=20.19'} hasBin: true peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.167.5 + '@tanstack/react-router': ^1.168.8 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1647,8 +1621,8 @@ packages: resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} engines: {node: '>=20.19'} - '@tanstack/store@0.9.2': - resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} '@tanstack/virtual-file-routes@1.161.7': resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} @@ -1677,21 +1651,15 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1733,73 +1701,80 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.57.1': - resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.57.1 + '@typescript-eslint/parser': ^8.57.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.57.1': - resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.57.1': - resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.57.1': - resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.57.1': - resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.57.1': - resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.57.1': - resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.57.1': - resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.57.1': - resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.57.1': - resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -1881,8 +1856,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.9: - resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + baseline-browser-mapping@2.10.12: + resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1894,14 +1869,11 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1933,16 +1905,12 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001782: + resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2007,9 +1975,6 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -2125,8 +2090,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} dotenv@17.3.1: @@ -2144,8 +2109,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.321: - resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2221,25 +2186,21 @@ packages: peerDependencies: eslint: '>=8.40' - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@5.0.1: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@10.1.0: + resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: jiti: '*' @@ -2247,9 +2208,9 @@ packages: jiti: optional: true - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} @@ -2430,8 +2391,8 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -2441,10 +2402,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - globals@16.5.0: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} @@ -2461,14 +2418,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.1: - resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2510,8 +2463,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2542,10 +2495,10 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@25.8.20: - resolution: {integrity: sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==} + i18next@25.10.10: + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} peerDependencies: - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: typescript: optional: true @@ -2696,8 +2649,8 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - jotai@2.18.1: - resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==} + jotai@2.19.0: + resolution: {integrity: sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -2847,9 +2800,6 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -3038,9 +2988,6 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -3051,8 +2998,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.13: - resolution: {integrity: sha512-9CV2mXT9+z0J26MQDfEZZkj/psJ5Er/w0w+t95FWdaGH/DTlhNZBx8vBO5jSYv8AZEnl3ouX+AaTT68KXdAIag==} + msw@2.12.14: + resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3197,8 +3144,8 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3206,12 +3153,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -3350,14 +3297,14 @@ packages: peerDependencies: react: ^19.2.4 - react-i18next@16.5.8: - resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} + react-i18next@16.6.6: + resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==} peerDependencies: - i18next: '>= 25.6.2' + i18next: '>= 25.10.9' react: '>= 16.8.0' react-dom: '*' react-native: '*' - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: react-dom: optional: true @@ -3372,10 +3319,6 @@ packages: '@types/react': '>=18' react: '>=18' - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3468,9 +3411,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true router@2.2.0: @@ -3520,8 +3463,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.1.0: - resolution: {integrity: sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==} + shadcn@4.1.1: + resolution: {integrity: sha512-nBj+7LYC9kzV9v9QmRPpoOhfW4KctJVQejywdAt/K+K+z4RYlJOcO2a4AaF7elrRWkfCbgXeGK02liV0KB9HvQ==} hasBin: true shebang-command@2.0.0: @@ -3629,20 +3572,12 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -3653,25 +3588,22 @@ packages: tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tiny-warning@1.0.3: - resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tldts-core@7.0.26: - resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} - tldts@7.0.26: - resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} hasBin: true to-regex-range@5.0.1: @@ -3728,8 +3660,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.57.1: - resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} + typescript-eslint@8.57.2: + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3861,15 +3793,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 jiti: '>=1.21.0' less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -3880,12 +3813,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -3975,10 +3910,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} @@ -4138,16 +4073,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -4195,22 +4120,38 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dotenvx/dotenvx@1.57.0': + '@dotenvx/dotenvx@1.59.1': dependencies: commander: 11.1.0 dotenv: 17.3.1 eciesjs: 0.4.18 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.4 which: 4.0.0 '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.4': optional: true @@ -4289,50 +4230,38 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.1.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.23.3': dependencies: - '@eslint/object-schema': 2.1.7 + '@eslint/object-schema': 3.0.3 debug: 4.4.3 - minimatch: 3.1.5 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.4.2': + '@eslint/config-helpers@0.5.3': dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.1 - '@eslint/core@0.17.0': + '@eslint/core@1.1.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.1.0(jiti@2.6.1) + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 + '@eslint/core': 1.1.1 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -4354,9 +4283,9 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.11(hono@4.12.8)': + '@hono/node-server@1.19.11(hono@4.12.9)': dependencies: - hono: 4.12.8 + hono: 4.12.9 '@humanfs/core@0.19.1': {} @@ -4416,9 +4345,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) + '@hono/node-server': 1.19.11(hono@4.12.9) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4428,13 +4357,13 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 + hono: 4.12.9 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -4447,6 +4376,13 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -4476,6 +4412,8 @@ snapshots: '@open-draft/until@2.1.0': {} + '@oxc-project/types@0.122.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -5223,93 +5161,70 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.59.0': + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true - '@rollup/rollup-android-arm64@4.59.0': + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': optional: true - '@rollup/rollup-darwin-arm64@4.59.0': + '@rolldown/binding-darwin-x64@1.0.0-rc.12': optional: true - '@rollup/rollup-darwin-x64@4.59.0': + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': optional: true - '@rollup/rollup-freebsd-arm64@4.59.0': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': optional: true - '@rollup/rollup-freebsd-x64@4.59.0': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.0': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.0': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.0': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.0': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.0': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.0': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.0': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.0': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true + '@rolldown/pluginutils@1.0.0-rc.12': {} - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true + '@rolldown/pluginutils@1.0.0-rc.7': {} '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} - '@tabler/icons-react@3.40.0(react@19.2.4)': + '@tabler/icons-react@3.41.1(react@19.2.4)': dependencies: - '@tabler/icons': 3.40.0 + '@tabler/icons': 3.41.1 react: 19.2.4 - '@tabler/icons@3.40.0': {} + '@tabler/icons@3.41.1': {} '@tailwindcss/node@4.2.2': dependencies: @@ -5377,73 +5292,67 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.91.2': {} + '@tanstack/query-core@5.95.2': {} - '@tanstack/react-query@5.91.2(react@19.2.4)': + '@tanstack/react-query@5.95.2(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.91.2 + '@tanstack/query-core': 5.95.2 react: 19.2.4 - '@tanstack/react-router-devtools@1.166.9(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.5)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3) + '@tanstack/react-router': 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.167.5 + '@tanstack/router-core': 1.168.7 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.6 - '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.167.5 + '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.168.7 isbot: 5.1.36 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/store': 0.9.2 + '@tanstack/store': 0.9.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/router-core@1.167.5': + '@tanstack/router-core@1.168.7': dependencies: '@tanstack/history': 1.161.6 - '@tanstack/store': 0.9.2 cookie-es: 2.0.0 seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.166.9(@tanstack/router-core@1.167.5)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.167.5 + '@tanstack/router-core': 1.168.7 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) - tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.166.13': + '@tanstack/router-generator@1.166.22': dependencies: - '@tanstack/router-core': 1.167.5 + '@tanstack/router-core': 1.168.7 '@tanstack/router-utils': 1.161.6 '@tanstack/virtual-file-routes': 1.161.7 prettier: 3.8.1 @@ -5454,7 +5363,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.166.14(@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5462,16 +5371,16 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.167.5 - '@tanstack/router-generator': 1.166.13 + '@tanstack/router-core': 1.168.7 + '@tanstack/router-generator': 1.166.22 '@tanstack/router-utils': 1.161.6 '@tanstack/virtual-file-routes': 1.161.7 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) + '@tanstack/react-router': 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5483,13 +5392,13 @@ snapshots: '@babel/types': 7.29.0 ansis: 4.2.0 babel-dead-code-elimination: 1.0.12 - diff: 8.0.3 + diff: 8.0.4 pathe: 2.0.3 tinyglobby: 0.2.15 transitivePeerDependencies: - supports-color - '@tanstack/store@0.9.2': {} + '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.161.7': {} @@ -5513,31 +5422,17 @@ snapshots: minimatch: 10.2.4 path-browserify: 1.0.1 - '@types/babel__core@7.20.5': + '@tybys/wasm-util@0.10.1': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + tslib: 2.8.1 + optional: true '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5576,15 +5471,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.1 - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + eslint: 10.1.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5592,56 +5487,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.1.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.57.1': + '@typescript-eslint/scope-manager@8.57.2': dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 - '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.1.0(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.57.1': {} + '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 @@ -5651,35 +5546,28 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + eslint: 10.1.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.57.1': + '@typescript-eslint/visitor-keys@8.57.2': dependencies: - '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/types': 8.57.2 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0) - transitivePeerDependencies: - - supports-color + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) accepts@2.0.0: dependencies: @@ -5727,7 +5615,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} @@ -5754,7 +5642,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.9: {} + baseline-browser-mapping@2.10.12: {} binary-extensions@2.3.0: {} @@ -5772,16 +5660,11 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5791,9 +5674,9 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.9 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.321 + baseline-browser-mapping: 2.10.12 + caniuse-lite: 1.0.30001782 + electron-to-chromium: 1.5.328 node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -5815,15 +5698,10 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001782: {} ccount@2.0.1: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -5880,8 +5758,6 @@ snapshots: commander@14.0.3: {} - concat-map@0.0.1: {} - content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -5959,7 +5835,7 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.3: {} + diff@8.0.4: {} dotenv@17.3.1: {} @@ -5978,7 +5854,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.321: {} + electron-to-chromium@1.5.328: {} emoji-regex@10.6.0: {} @@ -5989,7 +5865,7 @@ snapshots: enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.2 entities@6.0.1: {} @@ -6044,58 +5920,55 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.1.0(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.1.0(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-refresh@0.4.26(eslint@10.1.0(jiti@2.6.1)): dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 10.1.0(jiti@2.6.1) - eslint-scope@8.4.0: + eslint-scope@9.1.2: dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.1: {} - eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1): + eslint@10.1.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 ajv: 6.14.0 - chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -6106,8 +5979,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -6115,11 +5987,11 @@ snapshots: transitivePeerDependencies: - supports-color - espree@10.4.0: + espree@11.2.0: dependencies: acorn: 8.16.0 acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 + eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} @@ -6232,9 +6104,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fetch-blob@3.2.0: dependencies: @@ -6332,7 +6204,7 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.13.6: + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -6344,8 +6216,6 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@14.0.0: {} - globals@16.5.0: {} goober@2.1.18(csstype@3.2.3): @@ -6356,9 +6226,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.1: {} - - has-flag@4.0.0: {} + graphql@16.13.2: {} has-symbols@1.1.0: {} @@ -6453,7 +6321,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.8: {} + hono@4.12.9: {} html-parse-stringify@3.0.1: dependencies: @@ -6486,7 +6354,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@25.8.20(typescript@5.9.3): + i18next@25.10.10(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -6586,7 +6454,7 @@ snapshots: jose@6.2.2: {} - jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + jotai@2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): optionalDependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 @@ -6691,8 +6559,6 @@ snapshots: lodash-es@4.17.23: {} - lodash.merge@4.6.2: {} - log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -7067,7 +6933,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.54.0: {} @@ -7081,28 +6947,24 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.4 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.12 + brace-expansion: 5.0.5 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 minimist@1.2.8: {} ms@2.1.3: {} - msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3): + msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@25.5.0) '@mswjs/interceptors': 0.41.3 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.1 + graphql: 16.13.2 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -7256,15 +7118,15 @@ snapshots: path-to-regexp@6.3.0: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.0: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pkce-challenge@5.0.1: {} @@ -7397,11 +7259,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.5.8(i18next@25.8.20(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 25.8.20(typescript@5.9.3) + i18next: 25.10.10(typescript@5.9.3) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: @@ -7426,8 +7288,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-refresh@0.18.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -7468,7 +7328,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 recast@0.23.11: dependencies: @@ -7540,36 +7400,29 @@ snapshots: reusify@1.1.0: {} - rollup@4.59.0: + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: - '@types/estree': 1.0.8 + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' router@2.2.0: dependencies: @@ -7577,7 +7430,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.0 transitivePeerDependencies: - supports-color @@ -7628,28 +7481,28 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.1.0(@types/node@25.5.0)(typescript@5.9.3): + shadcn@4.1.1(@types/node@25.5.0)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.57.0 - '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@dotenvx/dotenvx': 1.59.1 + '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 deepmerge: 4.3.1 - diff: 8.0.3 + diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 fs-extra: 11.3.4 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.13(@types/node@25.5.0)(typescript@5.9.3) + msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 @@ -7663,7 +7516,7 @@ snapshots: tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -7772,8 +7625,6 @@ snapshots: strip-final-newline@4.0.0: {} - strip-json-comments@3.1.1: {} - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -7782,32 +7633,26 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} tailwindcss@4.2.2: {} - tapable@2.3.0: {} + tapable@2.3.2: {} tiny-invariant@1.3.3: {} - tiny-warning@1.0.3: {} - tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tldts-core@7.0.26: {} + tldts-core@7.0.27: {} - tldts@7.0.26: + tldts@7.0.27: dependencies: - tldts-core: 7.0.26 + tldts-core: 7.0.27 to-regex-range@5.0.1: dependencies: @@ -7817,7 +7662,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.26 + tldts: 7.0.27 trim-lines@3.0.1: {} @@ -7843,7 +7688,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.4 - get-tsconfig: 4.13.6 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -7863,13 +7708,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.1.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7921,7 +7766,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.16.0 - picomatch: 4.0.3 + picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 until-async@3.0.2: {} @@ -7995,20 +7840,22 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0): + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + lightningcss: 1.32.0 + picomatch: 4.0.4 postcss: 8.5.8 - rollup: 4.59.0 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.5.0 + esbuild: 0.27.4 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.32.0 tsx: 4.21.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' void-elements@3.1.0: {} @@ -8075,7 +7922,7 @@ snapshots: yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 From d844bf3683dbc420a3b27959655bc9fe3281ec60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:47:17 +0800 Subject: [PATCH 30/72] build(deps): bump github.com/github/copilot-sdk/go from 0.1.32 to 0.2.0 (#2058) Bumps [github.com/github/copilot-sdk/go](https://github.com/github/copilot-sdk) from 0.1.32 to 0.2.0. - [Release notes](https://github.com/github/copilot-sdk/releases) - [Changelog](https://github.com/github/copilot-sdk/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/copilot-sdk/compare/v0.1.32...v0.2.0) --- updated-dependencies: - dependency-name: github.com/github/copilot-sdk/go dependency-version: 0.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 +++++++- go.sum | 23 +++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 202839c29..04f065529 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,8 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -82,6 +84,10 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/text v0.35.0 // indirect modernc.org/libc v1.67.6 // indirect @@ -95,7 +101,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.1.32 + github.com/github/copilot-sdk/go v0.2.0 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/go.sum b/go.sum index c64f3593d..3fd1a0235 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,13 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo= -github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= +github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0= +github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -157,8 +162,9 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -209,8 +215,9 @@ github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoX github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -277,6 +284,14 @@ go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= From 74dfd9364c2e258806edd8eafbbc8e9534b5eefc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:51:19 +0800 Subject: [PATCH 31/72] build(deps): bump golang.org/x/time from 0.14.0 to 0.15.0 (#2059) Bumps [golang.org/x/time](https://github.com/golang/time) from 0.14.0 to 0.15.0. - [Commits](https://github.com/golang/time/compare/v0.14.0...v0.15.0) --- updated-dependencies: - dependency-name: golang.org/x/time dependency-version: 0.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 04f065529..5c0fcbd71 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.41.0 - golang.org/x/time v0.14.0 + golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.4 diff --git a/go.sum b/go.sum index 3fd1a0235..645bf2db7 100644 --- a/go.sum +++ b/go.sum @@ -378,8 +378,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= From fd9914dd92168af4a168799ede92fc26832c30a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:58:04 +0800 Subject: [PATCH 32/72] build(deps): bump github.com/aws/aws-sdk-go-v2/service/bedrockruntime (#2061) Bumps [github.com/aws/aws-sdk-go-v2/service/bedrockruntime](https://github.com/aws/aws-sdk-go-v2) from 1.50.2 to 1.50.3. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.50.2...service/s3/v1.50.3) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/service/bedrockruntime dependency-version: 1.50.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 12 ++++++------ go.sum | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 5c0fcbd71..14be4420b 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,10 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 - github.com/aws/aws-sdk-go-v2 v1.41.4 + github.com/atotto/clipboard v0.1.4 + github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.12 - github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/creack/pty v1.1.24 @@ -46,12 +47,11 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect diff --git a/go.sum b/go.sum index 645bf2db7..4ceda90d4 100644 --- a/go.sum +++ b/go.sum @@ -19,24 +19,24 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= From 5c6e13e188e617a390e28caa220d0629256e68e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:05:11 +0800 Subject: [PATCH 33/72] build(deps): bump modernc.org/sqlite from 1.46.1 to 1.47.0 (#2063) Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.46.1 to 1.47.0. - [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md) - [Commits](https://gitlab.com/cznic/sqlite/compare/v1.46.1...v1.47.0) --- updated-dependencies: - dependency-name: modernc.org/sqlite dependency-version: 1.47.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 14be4420b..7d242d498 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.4 - modernc.org/sqlite v1.46.1 + modernc.org/sqlite v1.47.0 rsc.io/qr v0.2.0 ) @@ -90,7 +90,7 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/text v0.35.0 // indirect - modernc.org/libc v1.67.6 // indirect + modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 4ceda90d4..76d1b46c7 100644 --- a/go.sum +++ b/go.sum @@ -422,18 +422,18 @@ maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -442,8 +442,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 7dc0d02a5e51933c799ccd1bbc5e1913324a9493 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:14:20 +0800 Subject: [PATCH 34/72] build(deps): bump i18next from 25.8.20 to 25.10.10 in /web/frontend (#2065) Bumps [i18next](https://github.com/i18next/i18next) from 25.8.20 to 25.10.10. - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v25.8.20...v25.10.10) --- updated-dependencies: - dependency-name: i18next dependency-version: 25.10.10 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index fc993451e..426989c38 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -25,7 +25,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", - "i18next": "^25.8.14", + "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.18.1", "radix-ui": "^1.4.3", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 36217d0ef..f72a50892 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^1.11.20 version: 1.11.20 i18next: - specifier: ^25.8.14 - version: 25.10.10(typescript@5.9.3) + specifier: ^26.0.1 + version: 26.0.1(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -55,7 +55,7 @@ importers: version: 19.2.4(react@19.2.4) react-i18next: specifier: ^16.5.8 - version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + version: 16.6.6(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -2495,8 +2495,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@25.10.10: - resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} + i18next@26.0.1: + resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -6354,7 +6354,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@25.10.10(typescript@5.9.3): + i18next@26.0.1(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -7259,11 +7259,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@16.6.6(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 25.10.10(typescript@5.9.3) + i18next: 26.0.1(typescript@5.9.3) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: From 5e1b6a397157b945ef953e51767febba7b8c1cf5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:18:08 +0800 Subject: [PATCH 35/72] build(deps-dev): bump globals from 16.5.0 to 17.4.0 in /web/frontend (#2067) Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.4.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.4.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.4.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 426989c38..906425b58 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -58,7 +58,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", - "globals": "^16.5.0", + "globals": "^17.4.0", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index f72a50892..abb906c81 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -130,8 +130,8 @@ importers: specifier: ^0.4.26 version: 0.4.26(eslint@10.1.0(jiti@2.6.1)) globals: - specifier: ^16.5.0 - version: 16.5.0 + specifier: ^17.4.0 + version: 17.4.0 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -2402,8 +2402,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} goober@2.1.18: @@ -6216,7 +6216,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@16.5.0: {} + globals@17.4.0: {} goober@2.1.18(csstype@3.2.3): dependencies: From 5e7545a22a9bb9c252b5db6e7deecb8695f382a3 Mon Sep 17 00:00:00 2001 From: mattn Date: Mon, 30 Mar 2026 17:30:25 +0900 Subject: [PATCH 36/72] perf: precompute BM25 index for repeated searches (#2177) --- pkg/utils/bm25.go | 151 +++++++++++++++++++++++------------------ pkg/utils/bm25_test.go | 60 ++++++++++++++++ 2 files changed, 144 insertions(+), 67 deletions(-) diff --git a/pkg/utils/bm25.go b/pkg/utils/bm25.go index 95c63f0e3..f8b9f6882 100644 --- a/pkg/utils/bm25.go +++ b/pkg/utils/bm25.go @@ -29,18 +29,18 @@ const ( DefaultBM25B = 0.75 ) -// BM25Engine is a query-time BM25 search engine over a generic corpus. +// BM25Engine is a BM25 search engine over a generic corpus. // T is the document type; the caller supplies a TextFunc that extracts the // searchable text from each document. // -// The engine is stateless between queries: no caching, no invalidation logic. -// All indexing work is performed inside Search() on every call, making it -// safe to use on corpora that change frequently. +// The engine precomputes its index once at construction time and reuses it for +// subsequent searches. If the corpus content changes, construct a new engine. type BM25Engine[T any] struct { corpus []T textFunc func(T) string k1 float64 b float64 + index *bm25Index } // BM25Option is a functional option to configure a BM25Engine. @@ -51,6 +51,17 @@ type bm25Config struct { b float64 } +type bm25Index struct { + entries []bm25DocEntry + idf map[string]float32 + docLenNorm []float32 + posting map[string][]int32 +} + +type bm25DocEntry struct { + tf map[string]uint32 +} + // WithK1 overrides the term-frequency saturation constant (default 1.2). func WithK1(k1 float64) BM25Option { return func(c *bm25Config) { c.k1 = k1 } @@ -74,12 +85,14 @@ func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Optio for _, o := range opts { o(&cfg) } - return &BM25Engine[T]{ + engine := &BM25Engine[T]{ corpus: corpus, textFunc: textFunc, k1: cfg.k1, b: cfg.b, } + engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b) + return engine } // BM25Result is a single ranked result from a Search call. @@ -91,9 +104,8 @@ type BM25Result[T any] struct { // Search ranks the corpus against query and returns the top-k results. // Returns an empty slice (not nil) when there are no matches. // -// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring, -// where N = corpus size, L = average document length, Q = query terms. -// Top-k extraction uses a fixed-size min-heap: O(candidates × log k). +// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the +// one-time indexing work performed by NewBM25Engine. func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { if topK <= 0 { return []BM25Result[T]{} @@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { return []BM25Result[T]{} } - N := len(e.corpus) - if N == 0 { + if len(e.corpus) == 0 || e.index == nil { return []BM25Result[T]{} } - // Step 1: build per-document tf + raw doc lengths - type docEntry struct { - tf map[string]uint32 - rawLen int - } - - entries := make([]docEntry, N) - df := make(map[string]int, 64) - totalLen := 0 - - for i, doc := range e.corpus { - tokens := bm25Tokenize(e.textFunc(doc)) - totalLen += len(tokens) - - tf := make(map[string]uint32, len(tokens)) - for _, t := range tokens { - tf[t]++ - } - // df: each term counts once per document (iterate the map, keys are unique) - for t := range tf { - df[t]++ - } - - entries[i] = docEntry{tf: tf, rawLen: len(tokens)} - } - - avgDocLen := float64(totalLen) / float64(N) - - // Step 2: pre-compute IDF and per-doc length normalization - // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 ) - idf := make(map[string]float32, len(df)) - for term, freq := range df { - idf[term] = float32(math.Log( - (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1, - )) - } - - // docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen) - // Stored as float32 — sufficient precision for ranking. - docLenNorm := make([]float32, N) - for i, entry := range entries { - docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen)) - } - - // Step 3: build inverted index (posting lists) - // Iterate the tf map directly — map keys are already unique, no seen-set needed. - posting := make(map[string][]int32, len(df)) - for i, entry := range entries { - for term := range entry.tf { - posting[term] = append(posting[term], int32(i)) - } - } - // Step 4: score via posting lists // Deduplicate query terms to avoid double-weighting the same term. unique := bm25Dedupe(queryTerms) scores := make(map[int32]float32) for _, term := range unique { - termIDF, ok := idf[term] + termIDF, ok := e.index.idf[term] if !ok { continue // term not in vocabulary → zero contribution } - for _, docID := range posting[term] { - freq := float32(entries[docID].tf[term]) + for _, docID := range e.index.posting[term] { + freq := float32(e.index.entries[docID].tf[term]) // TF_norm = freq * (k1+1) / (freq + docLenNorm) - tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID]) + tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID]) scores[docID] += termIDF * tfNorm } } @@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { return out } +func buildBM25Index[T any](corpus []T, textFunc func(T) string, k1, b float64) *bm25Index { + N := len(corpus) + if N == 0 { + return nil + } + + entries := make([]bm25DocEntry, N) + rawLens := make([]int, N) + df := make(map[string]int, 64) + totalLen := 0 + + for i, doc := range corpus { + tokens := bm25Tokenize(textFunc(doc)) + totalLen += len(tokens) + rawLens[i] = len(tokens) + + tf := make(map[string]uint32, len(tokens)) + for _, t := range tokens { + tf[t]++ + } + for term := range tf { + df[term]++ + } + + entries[i] = bm25DocEntry{tf: tf} + } + + avgDocLen := float64(totalLen) / float64(N) + if avgDocLen == 0 { + avgDocLen = 1 + } + + idf := make(map[string]float32, len(df)) + for term, freq := range df { + idf[term] = float32(math.Log( + (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1, + )) + } + + docLenNorm := make([]float32, N) + for i, rawLen := range rawLens { + docLenNorm[i] = float32(k1 * (1 - b + b*float64(rawLen)/avgDocLen)) + } + + posting := make(map[string][]int32, len(df)) + for i, entry := range entries { + for term := range entry.tf { + posting[term] = append(posting[term], int32(i)) + } + } + + return &bm25Index{ + entries: entries, + idf: idf, + docLenNorm: docLenNorm, + posting: posting, + } +} + // bm25Tokenize splits s into lowercase tokens, stripping edge punctuation. func bm25Tokenize(s string) []string { raw := strings.Fields(strings.ToLower(s)) diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go index 4bc85b246..216fe733d 100644 --- a/pkg/utils/bm25_test.go +++ b/pkg/utils/bm25_test.go @@ -1,7 +1,9 @@ package utils import ( + "fmt" "reflect" + "strings" "testing" ) @@ -173,3 +175,61 @@ func TestBM25Search_SortingStability(t *testing.T) { } } } + +func BenchmarkBM25Search_ReusedIndex(b *testing.B) { + corpus := benchmarkBM25Corpus(2000) + engine := NewBM25Engine(corpus, extractText) + query := "hardware gpio i2c sensor controller latency" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + results := engine.Search(query, 10) + if len(results) == 0 { + b.Fatal("expected non-empty results") + } + } +} + +func BenchmarkBM25Search_RebuildEachTime(b *testing.B) { + corpus := benchmarkBM25Corpus(2000) + query := "hardware gpio i2c sensor controller latency" + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + engine := NewBM25Engine(corpus, extractText) + results := engine.Search(query, 10) + if len(results) == 0 { + b.Fatal("expected non-empty results") + } + } +} + +func benchmarkBM25Corpus(size int) []testDoc { + corpus := make([]testDoc, size) + topics := []string{ + "hardware gpio pwm adc sensor controller latency throughput", + "telegram markdown parser message escape formatting bot command", + "jsonl memory session history storage append compact recovery", + "openai provider routing agent tool search registry hidden tools", + "i2c spi uart serial device bus address transfer clock", + } + + for i := range corpus { + topic := topics[i%len(topics)] + corpus[i] = testDoc{ + ID: i, + Text: fmt.Sprintf( + "doc %d %s repeated repeated %s variant-%d %s", + i, + topic, + topic, + i%17, + strings.Repeat("token ", (i%7)+1), + ), + } + } + + return corpus +} From e88df4ff9c822457b60063d80891ea33af8be0f0 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Mon, 30 Mar 2026 16:31:34 +0800 Subject: [PATCH 37/72] feat(tools): add reaction tool and reply-aware message sends (#2156) - Add `reaction` tool that reacts to a message (defaults to current inbound message via context) - Extend `message` tool with optional `reply_to_message_id` parameter - Introduce `WithToolInboundContext` to inject inbound message IDs into tool execution context - Surface `MessageID` and `ReplyToMessageID` in `processOptions` for tool-surface consumption Refs #2137 --- pkg/agent/loop.go | 42 +++++++++++++++-- pkg/agent/loop_test.go | 14 ++++++ pkg/tools/base.go | 35 +++++++++++++- pkg/tools/message.go | 9 +++- pkg/tools/message_test.go | 41 ++++++++++++++-- pkg/tools/reaction.go | 87 ++++++++++++++++++++++++++++++++++ pkg/tools/reaction_test.go | 96 ++++++++++++++++++++++++++++++++++++++ pkg/tools/registry_test.go | 27 +++++++++++ 8 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 pkg/tools/reaction.go create mode 100644 pkg/tools/reaction_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ef2951365..d7461e76f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -75,6 +75,8 @@ type processOptions struct { SessionKey string // Session identifier for history/context Channel string // Target channel for tool execution ChatID string // Target chat ID for tool execution + MessageID string // Current inbound platform message ID + ReplyToMessageID string // Current inbound reply target message ID SenderID string // Current sender ID for dynamic context SenderDisplayName string // Current sender display name for dynamic context UserMessage string // User message content (may include prefix) @@ -104,6 +106,7 @@ const ( metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" + metadataKeyReplyToMessage = "reply_to_message_id" metadataKeyParentPeerKind = "parent_peer_kind" metadataKeyParentPeerID = "parent_peer_id" ) @@ -222,17 +225,37 @@ func registerSharedTools( // Message tool if cfg.Tools.IsToolEnabled("message") { messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { + messageTool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pubCancel() return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, + Channel: channel, + ChatID: chatID, + Content: content, + ReplyToMessageID: replyToMessageID, }) }) agent.Tools.Register(messageTool) } + if cfg.Tools.IsToolEnabled("reaction") { + reactionTool := tools.NewReactionTool() + reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not configured") + } + ch, ok := al.channelManager.GetChannel(channel) + if !ok { + return fmt.Errorf("channel %s not found", channel) + } + rc, ok := ch.(channels.ReactionCapable) + if !ok { + return fmt.Errorf("channel %s does not support reactions", channel) + } + _, err := rc.ReactToMessage(ctx, chatID, messageID) + return err + }) + agent.Tools.Register(reactionTool) + } // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) if cfg.Tools.IsToolEnabled("send_file") { @@ -1315,6 +1338,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, + MessageID: msg.MessageID, + ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage), SenderID: msg.SenderID, SenderDisplayName: msg.Sender.DisplayName, UserMessage: msg.Content, @@ -2384,8 +2409,15 @@ turnLoop: } toolStart := time.Now() - toolResult := ts.agent.Tools.ExecuteWithContext( + execCtx := tools.WithToolInboundContext( turnCtx, + ts.channel, + ts.chatID, + ts.opts.MessageID, + ts.opts.ReplyToMessageID, + ) + toolResult := ts.agent.Tools.ExecuteWithContext( + execCtx, toolName, toolArgs, ts.channel, diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 25d20c689..58149f92c 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -531,6 +531,20 @@ func TestToolContext_Updates(t *testing.T) { if got := tools.ToolChannel(context.Background()); got != "" { t.Errorf("expected empty channel from bare context, got %q", got) } + + inboundCtx := tools.WithToolInboundContext( + context.Background(), + "telegram", + "chat-42", + "msg-123", + "msg-100", + ) + if got := tools.ToolMessageID(inboundCtx); got != "msg-123" { + t.Errorf("expected messageID 'msg-123', got %q", got) + } + if got := tools.ToolReplyToMessageID(inboundCtx); got != "msg-100" { + t.Errorf("expected replyToMessageID 'msg-100', got %q", got) + } } // TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved diff --git a/pkg/tools/base.go b/pkg/tools/base.go index ec743e164..afee95692 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -21,8 +21,10 @@ type Tool interface { type toolCtxKey struct{ name string } var ( - ctxKeyChannel = &toolCtxKey{"channel"} - ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeyChannel = &toolCtxKey{"channel"} + ctxKeyChatID = &toolCtxKey{"chatID"} + ctxKeyMessageID = &toolCtxKey{"messageID"} + ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"} ) // WithToolContext returns a child context carrying channel and chatID. @@ -32,6 +34,23 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex return ctx } +// WithToolMessageContext returns a child context carrying inbound message IDs. +func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { + ctx = context.WithValue(ctx, ctxKeyMessageID, messageID) + ctx = context.WithValue(ctx, ctxKeyReplyToMessageID, replyToMessageID) + return ctx +} + +// WithToolInboundContext returns a child context carrying channel/chat and inbound IDs. +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + ctx = WithToolContext(ctx, channel, chatID) + ctx = WithToolMessageContext(ctx, messageID, replyToMessageID) + return ctx +} + // ToolChannel extracts the channel from ctx, or "" if unset. func ToolChannel(ctx context.Context) string { v, _ := ctx.Value(ctxKeyChannel).(string) @@ -44,6 +63,18 @@ func ToolChatID(ctx context.Context) string { return v } +// ToolMessageID extracts the current inbound message ID from ctx, or "" if unset. +func ToolMessageID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyMessageID).(string) + return v +} + +// ToolReplyToMessageID extracts the current inbound reply target from ctx, or "" if unset. +func ToolReplyToMessageID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyReplyToMessageID).(string) + return v +} + // AsyncCallback is a function type that async tools use to notify completion. // When an async tool finishes its work, it calls this callback with the result. // diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 438ceeddd..064065a38 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -6,7 +6,7 @@ import ( "sync/atomic" ) -type SendCallback func(channel, chatID, content string) error +type SendCallback func(channel, chatID, content, replyToMessageID string) error type MessageTool struct { sendCallback SendCallback @@ -41,6 +41,10 @@ func (t *MessageTool) Parameters() map[string]any { "type": "string", "description": "Optional: target chat/user ID", }, + "reply_to_message_id": map[string]any{ + "type": "string", + "description": "Optional: reply target message ID for channels that support threaded replies", + }, }, "required": []string{"content"}, } @@ -69,6 +73,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes channel, _ := args["channel"].(string) chatID, _ := args["chat_id"].(string) + replyToMessageID, _ := args["reply_to_message_id"].(string) if channel == "" { channel = ToolChannel(ctx) @@ -85,7 +90,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes return &ToolResult{ForLLM: "Message sending not configured", IsError: true} } - if err := t.sendCallback(channel, chatID, content); err != nil { + if err := t.sendCallback(channel, chatID, content, replyToMessageID); err != nil { return &ToolResult{ ForLLM: fmt.Sprintf("sending message: %v", err), IsError: true, diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 05630972e..93a611ee0 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -10,7 +10,7 @@ func TestMessageTool_Execute_Success(t *testing.T) { tool := NewMessageTool() var sentChannel, sentChatID, sentContent string - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { sentChannel = channel sentChatID = chatID sentContent = content @@ -61,7 +61,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { tool := NewMessageTool() var sentChannel, sentChatID string - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { sentChannel = channel sentChatID = chatID return nil @@ -96,7 +96,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { tool := NewMessageTool() sendErr := errors.New("network error") - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { return sendErr }) @@ -149,7 +149,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { tool := NewMessageTool() // No WithToolContext — channel/chatID are empty - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { return nil }) @@ -251,4 +251,37 @@ func TestMessageTool_Parameters(t *testing.T) { if chatIDProp["type"] != "string" { t.Error("Expected chat_id type to be 'string'") } + + // Check reply_to_message_id property (optional) + replyToProp, ok := props["reply_to_message_id"].(map[string]any) + if !ok { + t.Error("Expected 'reply_to_message_id' property") + } + if replyToProp["type"] != "string" { + t.Error("Expected reply_to_message_id type to be 'string'") + } +} + +func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) { + tool := NewMessageTool() + + var sentReplyTo string + tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error { + sentReplyTo = replyToMessageID + return nil + }) + + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") + args := map[string]any{ + "content": "Reply test", + "reply_to_message_id": "msg-123", + } + + result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if sentReplyTo != "msg-123" { + t.Fatalf("expected reply_to_message_id msg-123, got %q", sentReplyTo) + } } diff --git a/pkg/tools/reaction.go b/pkg/tools/reaction.go new file mode 100644 index 000000000..3455b07a9 --- /dev/null +++ b/pkg/tools/reaction.go @@ -0,0 +1,87 @@ +package tools + +import ( + "context" + "fmt" +) + +type ReactionCallback func(ctx context.Context, channel, chatID, messageID string) error + +type ReactionTool struct { + reactionCallback ReactionCallback +} + +func NewReactionTool() *ReactionTool { + return &ReactionTool{} +} + +func (t *ReactionTool) Name() string { + return "reaction" +} + +func (t *ReactionTool) Description() string { + return "Add a reaction to a message. Defaults to the current inbound message when message_id is omitted." +} + +func (t *ReactionTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "message_id": map[string]any{ + "type": "string", + "description": "Optional: target message ID; defaults to the current inbound message", + }, + "channel": map[string]any{ + "type": "string", + "description": "Optional: target channel (telegram, whatsapp, etc.)", + }, + "chat_id": map[string]any{ + "type": "string", + "description": "Optional: target chat/user ID", + }, + }, + } +} + +func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) { + t.reactionCallback = callback +} + +func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + channel, _ := args["channel"].(string) + chatID, _ := args["chat_id"].(string) + messageID, _ := args["message_id"].(string) + + if channel == "" { + channel = ToolChannel(ctx) + } + if chatID == "" { + chatID = ToolChatID(ctx) + } + if messageID == "" { + messageID = ToolMessageID(ctx) + } + + if channel == "" || chatID == "" { + return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} + } + if messageID == "" { + return &ToolResult{ForLLM: "message_id is required", IsError: true} + } + if t.reactionCallback == nil { + return &ToolResult{ForLLM: "Reaction not configured", IsError: true} + } + + if err := t.reactionCallback(ctx, channel, chatID, messageID); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("adding reaction: %v", err), + IsError: true, + Err: err, + } + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Reaction added to %s:%s message %s", channel, chatID, messageID), + Silent: true, + } +} diff --git a/pkg/tools/reaction_test.go b/pkg/tools/reaction_test.go new file mode 100644 index 000000000..6fc90445a --- /dev/null +++ b/pkg/tools/reaction_test.go @@ -0,0 +1,96 @@ +package tools + +import ( + "context" + "errors" + "testing" +) + +func TestReactionTool_Execute_UsesContextMessageIDByDefault(t *testing.T) { + tool := NewReactionTool() + + var gotChannel, gotChatID, gotMessageID string + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + gotChannel = channel + gotChatID = chatID + gotMessageID = messageID + return nil + }) + + ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "") + result := tool.Execute(ctx, map[string]any{}) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if gotChannel != "telegram" || gotChatID != "chat-1" || gotMessageID != "msg-100" { + t.Fatalf("unexpected callback args: channel=%q chatID=%q messageID=%q", gotChannel, gotChatID, gotMessageID) + } +} + +func TestReactionTool_Execute_AllowsExplicitMessageIDOverride(t *testing.T) { + tool := NewReactionTool() + + var gotMessageID string + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + gotMessageID = messageID + return nil + }) + + ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-context", "") + result := tool.Execute(ctx, map[string]any{"message_id": "msg-explicit"}) + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if gotMessageID != "msg-explicit" { + t.Fatalf("expected explicit message id, got %q", gotMessageID) + } +} + +func TestReactionTool_Execute_MissingMessageID(t *testing.T) { + tool := NewReactionTool() + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { return nil }) + + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error") + } + if result.ForLLM != "message_id is required" { + t.Fatalf("unexpected error message: %q", result.ForLLM) + } +} + +func TestReactionTool_Execute_CallbackError(t *testing.T) { + tool := NewReactionTool() + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + return errors.New("unsupported") + }) + + ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error") + } + if result.Err == nil { + t.Fatal("expected wrapped error") + } +} + +func TestReactionTool_Parameters(t *testing.T) { + tool := NewReactionTool() + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("expected properties map") + } + if _, ok := props["message_id"]; !ok { + t.Fatal("expected message_id parameter") + } + if _, ok := props["channel"]; !ok { + t.Fatal("expected channel parameter") + } + if _, ok := props["chat_id"]; !ok { + t.Fatal("expected chat_id parameter") + } +} diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index db52749f6..16bd30928 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -190,6 +190,33 @@ func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) { } } +func TestToolRegistry_ExecuteWithContext_PreservesMessageContext(t *testing.T) { + r := NewToolRegistry() + ct := &mockContextAwareTool{ + mockRegistryTool: *newMockTool("ctx_tool", "needs context"), + } + r.Register(ct) + + baseCtx := WithToolMessageContext(context.Background(), "msg-123", "msg-100") + r.ExecuteWithContext(baseCtx, "ctx_tool", nil, "telegram", "chat-42", nil) + + if ct.lastCtx == nil { + t.Fatal("expected Execute to be called") + } + if got := ToolChannel(ct.lastCtx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := ToolChatID(ct.lastCtx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) + } + if got := ToolMessageID(ct.lastCtx); got != "msg-123" { + t.Errorf("expected messageID 'msg-123', got %q", got) + } + if got := ToolReplyToMessageID(ct.lastCtx); got != "msg-100" { + t.Errorf("expected replyToMessageID 'msg-100', got %q", got) + } +} + func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { r := NewToolRegistry() at := &mockAsyncRegistryTool{ From ff0266a40e7e0388bfe0e76d04ec8458680de7e4 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:44:50 +0800 Subject: [PATCH 38/72] feat(web): display backend version info in sidebar (#2087) * feat(web): display backend version info in sidebar * fix(web): improve version parsing and timeout behavior * refactor(web): remove useless --version fallback * feat(web): implement version info caching and improve retrieval logic * fix(web): clarify version timeout rationale * fix(web): harden gateway version probing and tests * style(web): split regexp to two lines for lint --- cmd/picoclaw-launcher-tui/ui/gateway.go | 12 +- web/backend/api/router.go | 3 + web/backend/api/version.go | 345 ++++++++++++++++++++ web/backend/api/version_test.go | 317 ++++++++++++++++++ web/frontend/src/api/system.ts | 11 + web/frontend/src/components/app-sidebar.tsx | 30 ++ web/frontend/src/i18n/locales/en.json | 6 + web/frontend/src/i18n/locales/zh.json | 6 + 8 files changed, 725 insertions(+), 5 deletions(-) create mode 100644 web/backend/api/version.go create mode 100644 web/backend/api/version_test.go diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go index 1138c12db..397b712f7 100644 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -35,24 +35,26 @@ func getPidPath() string { } func isProcessRunning(pid int) bool { - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) output, err := cmd.Output() if err != nil { return false } return strings.Contains(string(output), strconv.Itoa(pid)) - } else if runtime.GOOS == "darwin" { + case "darwin": cmd := exec.Command("ps", "aux") output, err := cmd.Output() if err != nil { return false } return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) + default: + // Linux and other unix-like systems. + _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + return err == nil } - // Linux - _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) - return err == nil } func getGatewayStatus() gatewayStatus { diff --git a/web/backend/api/router.go b/web/backend/api/router.go index ce652d4c4..af490d8b5 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) + // Runtime build/version metadata + h.registerVersionRoutes(mux) + // WeChat QR login flow h.registerWeixinRoutes(mux) diff --git a/web/backend/api/version.go b/web/backend/api/version.go new file mode 100644 index 000000000..6232b989b --- /dev/null +++ b/web/backend/api/version.go @@ -0,0 +1,345 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +type systemVersionResponse struct { + Version string `json:"version"` + GitCommit string `json:"git_commit,omitempty"` + BuildTime string `json:"build_time,omitempty"` + GoVersion string `json:"go_version"` +} + +type cachedSystemVersion struct { + value systemVersionResponse + gatewayPID int +} + +type systemVersionCache struct { + mu sync.Mutex + current cachedSystemVersion + hasCurrent bool + inflightCh chan struct{} +} + +func newSystemVersionCache() *systemVersionCache { + return &systemVersionCache{} +} + +var ( + // 15 seconds matches the gateway startup window used elsewhere in launcher flow, + // giving slow/embedded hosts enough time for first command invocation while + // staying independent from cross-file init ordering. + versionCmdTimeout = 15 * time.Second + maxVersionResolveAttempts = 3 + findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo + runPicoclawVersionOutput = executePicoclawVersion + currentGatewayVersionState = gatewayVersionState + launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig + versionInfoCache = newSystemVersionCache() + ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + versionLinePattern = regexp.MustCompile( + `^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` + + `(?:\s+\(git:\s*([^)]+)\))?\s*$`, + ) +) + +func (h *Handler) registerVersionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/version", h.handleGetVersion) +} + +// handleGetVersion returns runtime version information for web clients. +func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) { + versionInfo := h.resolveSystemVersionInfo(r.Context()) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(versionInfo); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// resolveSystemVersionInfo prefers the actual picoclaw binary version output, +// and falls back to launcher build metadata when command execution fails. +func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse { + for range maxVersionResolveAttempts { + gatewayPID, gatewayAlive := currentGatewayVersionState() + if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok { + return cached + } + + leader, ok := versionInfoCache.waitOrStart(ctx) + if !ok { + return fallbackSystemVersionInfo() + } + if !leader { + continue + } + + resolved := h.resolveSystemVersionInfoUncached(ctx) + gatewayPID, gatewayAlive = currentGatewayVersionState() + versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive) + return resolved + } + + return fallbackSystemVersionInfo() +} + +func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse { + if ctx == nil { + ctx = context.Background() + } + + fallback := fallbackSystemVersionInfo() + + execPath := strings.TrimSpace(findPicoclawBinaryForInfo()) + if execPath == "" { + return fallback + } + + cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout) + defer cancel() + + output, err := runPicoclawVersionOutput(cmdCtx, execPath) + if err != nil { + return fallback + } + + parsed, ok := parsePicoclawVersionOutput(output) + if !ok { + return fallback + } + + if parsed.GoVersion == "" { + parsed.GoVersion = fallback.GoVersion + if parsed.GoVersion == "" { + parsed.GoVersion = runtime.Version() + } + } + + return parsed +} + +func fallbackSystemVersionInfo() systemVersionResponse { + return launcherBuildInfoForVersion() +} + +func fallbackSystemVersionInfoFromConfig() systemVersionResponse { + buildTime, goVer := config.FormatBuildInfo() + return systemVersionResponse{ + Version: config.GetVersion(), + GitCommit: config.GitCommit, + BuildTime: buildTime, + GoVersion: goVer, + } +} + +// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher +// gateway start path when available, then falls back to launcher binary lookup. +// This keeps version probing aligned with the actual gateway startup behavior, +// so web and gateway do not drift onto different binaries. +func resolveGatewayBinaryForVersionInfo() string { + gateway.mu.Lock() + cmd := gateway.cmd + gateway.mu.Unlock() + + if cmd != nil { + if execPath := strings.TrimSpace(cmd.Path); execPath != "" { + return execPath + } + } + + return utils.FindPicoclawBinary() +} + +func gatewayVersionState() (int, bool) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if gateway.cmd == nil || gateway.cmd.Process == nil { + return 0, false + } + pid := gateway.cmd.Process.Pid + if pid <= 0 { + return 0, false + } + + return pid, isCmdProcessAliveLocked(gateway.cmd) +} + +func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) { + c.clearCurrentLocked() + } + + if c.hasCurrent { + return c.current.value, true + } + + return systemVersionResponse{}, false +} + +func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return false, false + } + + c.mu.Lock() + if c.inflightCh == nil { + c.inflightCh = make(chan struct{}) + c.mu.Unlock() + return true, true + } + waitCh := c.inflightCh + c.mu.Unlock() + + select { + case <-waitCh: + return false, true + case <-ctx.Done(): + return false, false + } +} + +func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) { + c.mu.Lock() + if gatewayAlive && gatewayPID > 0 { + c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID} + c.hasCurrent = true + } else { + c.clearCurrentLocked() + } + + inflightCh := c.inflightCh + c.inflightCh = nil + c.mu.Unlock() + + if inflightCh != nil { + close(inflightCh) + } +} + +func (c *systemVersionCache) clearCurrentLocked() { + c.hasCurrent = false + c.current = cachedSystemVersion{} +} + +func (c *systemVersionCache) resetForTest() { + c.mu.Lock() + defer c.mu.Unlock() + + c.current = cachedSystemVersion{} + c.hasCurrent = false + if c.inflightCh != nil { + close(c.inflightCh) + c.inflightCh = nil + } +} + +// executePicoclawVersion runs the version subcommand against the +// discovered picoclaw executable. +func executePicoclawVersion(ctx context.Context, execPath string) (string, error) { + out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput() + if err == nil { + return string(out), nil + } + + return string(out), fmt.Errorf("failed to execute version command: %w", err) +} + +// parsePicoclawVersionOutput extracts version/build/go fields from CLI output. +// It accepts banner/ANSI-decorated output and only requires the version line. +func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) { + var result systemVersionResponse + + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), "")) + if line == "" { + continue + } + + if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 { + candidateVersion := strings.TrimSpace(match[1]) + if !isLikelyVersionValue(candidateVersion) { + continue + } + result.Version = candidateVersion + if len(match) > 2 { + result.GitCommit = strings.TrimSpace(match[2]) + } + continue + } + + if buildValue, ok := strings.CutPrefix(line, "Build:"); ok { + result.BuildTime = strings.TrimSpace(buildValue) + continue + } + + if goValue, ok := strings.CutPrefix(line, "Go:"); ok { + result.GoVersion = strings.TrimSpace(goValue) + } + } + + if err := scanner.Err(); err != nil { + return systemVersionResponse{}, false + } + + if result.Version == "" { + return systemVersionResponse{}, false + } + + return result, true +} + +func isLikelyVersionValue(value string) bool { + v := strings.TrimSpace(strings.ToLower(value)) + if v == "" { + return false + } + if v == "dev" { + return true + } + + // Accept git-like short/long hashes even when they contain only letters (a-f). + if len(v) >= 7 && len(v) <= 40 { + allHex := true + for _, ch := range v { + if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') { + continue + } + allHex = false + break + } + if allHex { + return true + } + } + + for _, ch := range v { + if ch >= '0' && ch <= '9' { + return true + } + } + return false +} diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go new file mode 100644 index 000000000..31c5366ab --- /dev/null +++ b/web/backend/api/version_test.go @@ -0,0 +1,317 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "runtime" + "testing" +) + +func setupVersionTestIsolation(t *testing.T) { + t.Helper() + + originalGatewayState := currentGatewayVersionState + originalFinder := findPicoclawBinaryForInfo + originalRunner := runPicoclawVersionOutput + originalFallback := launcherBuildInfoForVersion + t.Cleanup(func() { + currentGatewayVersionState = originalGatewayState + findPicoclawBinaryForInfo = originalFinder + runPicoclawVersionOutput = originalRunner + launcherBuildInfoForVersion = originalFallback + versionInfoCache.resetForTest() + }) + + currentGatewayVersionState = func() (int, bool) { return 0, false } + versionInfoCache.resetForTest() +} + +func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"} + } + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil + } + + h := NewHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got systemVersionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got.Version != "v1.2.3" { + t.Fatalf("version = %q, want %q", got.Version, "v1.2.3") + } + if got.GitCommit != "deadbeef" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef") + } + if got.BuildTime != "2026-03-27T12:34:56Z" { + t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z") + } + if got.GoVersion != "go1.25.8" { + t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8") + } +} + +func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { + setupVersionTestIsolation(t) + + expected := systemVersionResponse{ + Version: "v9.9.9", + GitCommit: "cafebabe", + BuildTime: "2026-03-27T10:43:34+0000", + GoVersion: "go1.25.8", + } + launcherBuildInfoForVersion = func() systemVersionResponse { return expected } + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "", errors.New("binary unavailable") + } + + h := NewHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got systemVersionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got.Version != expected.Version { + t.Fatalf("version = %q, want %q", got.Version, expected.Version) + } + if got.GitCommit != expected.GitCommit { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit) + } + if got.BuildTime != expected.BuildTime { + t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime) + } + if got.GoVersion != expected.GoVersion { + t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion) + } +} + +func TestParsePicoclawVersionOutput(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n" + got, ok := parsePicoclawVersionOutput(raw) + if !ok { + t.Fatal("parsePicoclawVersionOutput() should parse valid output") + } + if got.Version != "18ec263" { + t.Fatalf("version = %q, want %q", got.Version, "18ec263") + } + if got.GitCommit != "18ec2631" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631") + } + if got.BuildTime != "2026-03-27T10:43:34+0000" { + t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000") + } + if got.GoVersion != "go1.25.8" { + t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8") + } +} + +func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "Usage: picoclaw version [flags]\n" + got, ok := parsePicoclawVersionOutput(raw) + if ok { + t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got) + } +} + +func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n" + got, ok := parsePicoclawVersionOutput(raw) + if !ok { + t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version") + } + if got.Version != "abcdefa" { + t.Fatalf("version = %q, want %q", got.Version, "abcdefa") + } + if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd") + } +} + +func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: ""} + } + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "picoclaw v1.0.0\n", nil + } + + h := NewHandler("") + got := h.resolveSystemVersionInfo(context.Background()) + if got.GoVersion != runtime.Version() { + t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version()) + } +} + +func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"} + } + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + + pid := 4321 + currentGatewayVersionState = func() (int, bool) { return pid, true } + + runCount := 0 + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + runCount++ + return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil + } + + h := NewHandler("") + first := h.resolveSystemVersionInfo(context.Background()) + second := h.resolveSystemVersionInfo(context.Background()) + + if first.Version != "v1.2.1" { + t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1") + } + if second.Version != "v1.2.1" { + t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1") + } + if runCount != 1 { + t.Fatalf("run count = %d, want %d", runCount, 1) + } +} + +func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"} + } + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + + alive := true + pid := 9876 + currentGatewayVersionState = func() (int, bool) { + if !alive { + return 0, false + } + return pid, true + } + + runCount := 0 + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + runCount++ + return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil + } + + h := NewHandler("") + first := h.resolveSystemVersionInfo(context.Background()) + second := h.resolveSystemVersionInfo(context.Background()) + + if first.Version != "v2.0.1" || second.Version != "v2.0.1" { + t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version) + } + if runCount != 1 { + t.Fatalf("run count after cache hit = %d, want %d", runCount, 1) + } + + alive = false + third := h.resolveSystemVersionInfo(context.Background()) + if third.Version != "v2.0.2" { + t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2") + } + if runCount != 2 { + t.Fatalf("run count after invalidation = %d, want %d", runCount, 2) + } +} + +func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) { + setupVersionTestIsolation(t) + + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"} + } + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + + runCount := 0 + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + runCount++ + return "picoclaw v9.9.9\n", nil + } + + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + h := NewHandler("") + got := h.resolveSystemVersionInfo(canceledCtx) + + if runCount != 0 { + t.Fatalf("run count = %d, want %d", runCount, 0) + } + if got.Version != "v3.0.0" { + t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0") + } +} + +func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) { + setupVersionTestIsolation(t) + + originalFinder := findPicoclawBinaryForInfo + t.Cleanup(func() { + findPicoclawBinaryForInfo = originalFinder + }) + + gateway.mu.Lock() + originalCmd := gateway.cmd + gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"} + gateway.mu.Unlock() + t.Cleanup(func() { + gateway.mu.Lock() + gateway.cmd = originalCmd + gateway.mu.Unlock() + }) + + got := resolveGatewayBinaryForVersionInfo() + if got != "/tmp/picoclaw-from-gateway" { + t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway") + } +} diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 2e2f36f15..dfc48b6b8 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -13,6 +13,13 @@ export interface LauncherConfig { allowed_cidrs: string[] } +export interface SystemVersionInfo { + version: string + git_commit?: string + build_time?: string + go_version: string +} + async function request(path: string, options?: RequestInit): Promise { const res = await launcherFetch(path, options) if (!res.ok) { @@ -62,3 +69,7 @@ export async function setLauncherConfig( body: JSON.stringify(payload), }) } + +export async function getSystemVersionInfo(): Promise { + return request("/api/system/version") +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 0e135c0c1..18bc9f092 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -10,10 +10,12 @@ import { IconSparkles, IconTools, } from "@tabler/icons-react" +import { useQuery } from "@tanstack/react-query" import { Link, useRouterState } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" +import { getSystemVersionInfo } from "@/api/system" import { Collapsible, CollapsibleContent, @@ -27,6 +29,7 @@ import { SidebarGroupLabel, SidebarMenu, SidebarMenuButton, + SidebarFooter, SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar" @@ -78,6 +81,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(), t, }) + const { data: versionInfo } = useQuery({ + queryKey: ["system", "version"], + queryFn: getSystemVersionInfo, + staleTime: 5 * 60 * 1000, + }) + + const versionText = versionInfo?.version ?? t("footer.version_unknown") const navGroups: NavGroup[] = React.useMemo(() => { return [ @@ -235,6 +245,26 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ))} + +
+
+ {t("footer.version")}:{" "} + {versionText} +
+ {versionInfo?.git_commit && ( +
+ {t("footer.commit")}:{" "} + {versionInfo.git_commit} +
+ )} + {versionInfo?.build_time && ( +
+ {t("footer.build")}:{" "} + {versionInfo.build_time} +
+ )} +
+
) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 38cdeb324..9d170a4c8 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -93,6 +93,12 @@ "labels": { "loading": "Loading..." }, + "footer": { + "version": "Version", + "commit": "Commit", + "build": "Build", + "version_unknown": "Unknown" + }, "credentials": { "description": "Manage OAuth and token-based credentials for supported providers.", "loading": "Loading credentials...", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 9ec4ec967..b214753ca 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -93,6 +93,12 @@ "labels": { "loading": "加载中..." }, + "footer": { + "version": "版本", + "commit": "提交", + "build": "构建", + "version_unknown": "未知" + }, "credentials": { "description": "管理已支持服务商的 OAuth 与 Token 凭据。", "loading": "正在加载凭据...", From 7a1f2aba03374aff1c8516b9e8755da49a061922 Mon Sep 17 00:00:00 2001 From: Cytown Date: Mon, 30 Mar 2026 17:43:10 +0800 Subject: [PATCH 39/72] add check for gateway port and fix logger.Fatal not record issue (#2185) --- pkg/gateway/gateway.go | 15 +++++++++-- pkg/logger/logger.go | 59 +++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index c35b3e744..c563a99a2 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -91,13 +91,17 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error defer panicFunc() if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil { - panic(fmt.Sprintf("error enabling file logging: %v", err)) + logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err)) } defer logger.DisableFileLogging() cfg, err := config.LoadConfig(configPath) if err != nil { - return fmt.Errorf("error loading config: %w", err) + logger.Fatalf("error loading config: %v", err) + } + + if err = preCheckConfig(cfg); err != nil { + logger.Fatalf("config pre-check failed: %v", err) } logger.SetLevelFromString(cfg.Gateway.LogLevel) @@ -214,6 +218,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error } } +func preCheckConfig(cfg *config.Config) error { + if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 { + return fmt.Errorf("invalid gateway port: %d, port must be between 1 and 65535", cfg.Gateway.Port) + } + return nil +} + func executeReload( ctx context.Context, agentLoop *agent.AgentLoop, diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 33079616a..6d2e31791 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -35,12 +35,13 @@ var ( FATAL: "FATAL", } - currentLevel = INFO - logger zerolog.Logger - fileLogger zerolog.Logger - logFile *os.File - once sync.Once - mu sync.RWMutex + currentLevel = INFO + logger zerolog.Logger + logFile *os.File + once sync.Once + mu sync.RWMutex + writers []io.Writer + consoleWriter zerolog.ConsoleWriter ) func init() { @@ -49,7 +50,7 @@ func init() { isTTY := term.IsTerminal(int(os.Stdout.Fd())) - consoleWriter := zerolog.ConsoleWriter{ + consoleWriter = zerolog.ConsoleWriter{ Out: os.Stdout, TimeFormat: "15:04:05", // TODO: make it configurable??? @@ -72,8 +73,9 @@ func init() { NoColor: !isTTY, } - logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() - fileLogger = zerolog.Logger{} + writers = append(writers, consoleWriter) + + logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger() }) } @@ -124,7 +126,15 @@ func SetConsoleLevel(level LogLevel) { func DisableConsole() { mu.Lock() defer mu.Unlock() - logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger() + writers[0] = io.Discard + logger = logger.Output(io.MultiWriter(writers...)) +} + +func EnableConsole() { + mu.Lock() + defer mu.Unlock() + writers[0] = consoleWriter + logger = logger.Output(io.MultiWriter(writers...)) } func GetLevel() LogLevel { @@ -182,7 +192,14 @@ func EnableFileLogging(filePath string) error { } logFile = newFile - fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger() + + if len(writers) != 1 { + return fmt.Errorf("failed to configure file logging: %w", err) + } + + writers = append(writers, logFile) + logger = logger.Output(io.MultiWriter(writers...)) + return nil } @@ -194,7 +211,10 @@ func DisableFileLogging() { logFile.Close() logFile = nil } - fileLogger = zerolog.Logger{} + if len(writers) > 1 { + writers = writers[:1] + logger = logger.Output(io.MultiWriter(writers...)) + } } func ConfigureFromEnv() { @@ -298,21 +318,8 @@ func logMessage(level LogLevel, component string, message string, fields map[str event.Str(Component, component) appendFields(event, fields) + event.CallerSkipFrame(skip).Msg(message) - - // Also log to file if enabled - if fileLogger.GetLevel() != zerolog.NoLevel { - fileEvent := getEvent(fileLogger, level) - - fileEvent.Str(Component, component) - - appendFields(fileEvent, fields) - fileEvent.CallerSkipFrame(skip).Msg(message) - } - - if level == FATAL { - os.Exit(1) - } } func appendFields(event *zerolog.Event, fields map[string]any) { From 010d807e61be9f1bb035e06b437f4a8d5d0a508c Mon Sep 17 00:00:00 2001 From: Cytown Date: Mon, 30 Mar 2026 17:59:56 +0800 Subject: [PATCH 40/72] update docs according to newest config version 2 (#2186) --- docs/config-versioning.md | 161 ++++++++++++------------- docs/configuration.md | 25 ++-- docs/credential_encryption.md | 4 +- docs/docker.md | 6 +- docs/fr/configuration.md | 6 +- docs/fr/docker.md | 6 +- docs/fr/providers.md | 33 ++--- docs/ja/configuration.md | 6 +- docs/ja/docker.md | 6 +- docs/ja/providers.md | 46 ++++--- docs/migration/model-list-migration.md | 61 +++++++--- docs/my/docker.md | 6 +- docs/providers.md | 45 +++---- docs/pt-br/configuration.md | 6 +- docs/pt-br/docker.md | 6 +- docs/pt-br/providers.md | 35 +++--- docs/security_configuration.md | 10 +- docs/vi/configuration.md | 6 +- docs/vi/docker.md | 6 +- docs/vi/providers.md | 35 +++--- docs/zh/configuration.md | 28 ++--- docs/zh/docker.md | 6 +- docs/zh/providers.md | 39 +++--- 23 files changed, 323 insertions(+), 265 deletions(-) diff --git a/docs/config-versioning.md b/docs/config-versioning.md index 36d7fdd25..b5cdaf990 100644 --- a/docs/config-versioning.md +++ b/docs/config-versioning.md @@ -11,24 +11,35 @@ PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgr - **Changes**: Added `version` field to Config struct - **Migration**: No structural changes needed for existing configs +### Version 2 +- **Introduction**: Model enable/disable support and channel config unification +- **Changes**: + - Added `enabled` field to `ModelConfig` — allows disabling individual model entries without removing them + - During V1→V2 migration, `enabled` is auto-inferred: models with API keys or the reserved `local-model` name are enabled; others default to disabled + - Migrated legacy channel fields: Discord `mention_only` → `group_trigger.mention_only`, OneBot `group_trigger_prefix` → `group_trigger.prefixes` + - V0 configs now migrate directly to CurrentVersion (V2) instead of going through V1 + - `makeBackup()` now uses date-only suffix (e.g., `config.json.20260330.bak`) and also backs up `.security.yml` + ## How It Works ### Automatic Migration When you load a config file: 1. The system first reads the `version` field from the JSON -2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.) +2. Based on the detected version, it loads the appropriate config struct (`configV0`, `configV1`, etc.) 3. If the loaded version is less than the latest, migrations are applied incrementally -4. The version number is updated automatically -5. The migrated config is automatically saved back to disk +4. Before saving, the system automatically creates a date-stamped backup of `config.json` and `.security.yml` +5. The version number is updated automatically +6. The migrated config is automatically saved back to disk ### Version Field The `version` field in `config.json` indicates the schema version: - `0` or missing: Legacy config (no version field) -- `1`: Current version with versioning support +- `1`: Previous version (will be auto-migrated to V2 on load) +- `2`: Current version ```json { - "version": 1, + "version": 2, "agents": {...}, ... } @@ -54,25 +65,25 @@ type ConfigV2 struct { ### Step 2: Update Current Config Version ```go -const CurrentConfigVersion = 2 // Increment this +const CurrentVersion = 2 // Increment this ``` ### Step 3: Add a Loader Function ```go -// loadConfigV2 loads a version 2 config -func loadConfigV2(data []byte) (*Config, error) { +// loadConfigV3 loads a version 3 config +func loadConfigV3(data []byte) (*Config, error) { cfg := DefaultConfig() - // Parse to ConfigV2 struct - var v2 ConfigV2 - if err := json.Unmarshal(data, &v2); err != nil { + // Parse to ConfigV3 struct + var v3 ConfigV3 + if err := json.Unmarshal(data, &v3); err != nil { return nil, err } // Convert to current Config - cfg.Version = v2.Version - cfg.Agents = v2.Agents + cfg.Version = v3.Version + cfg.Agents = v3.Agents // ... map other fields return cfg, nil @@ -82,29 +93,12 @@ func loadConfigV2(data []byte) (*Config, error) { ### Step 4: Add Migration Logic ```go -// applyMigration applies a single migration step from fromVersion to toVersion -func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) { - switch toVersion { - case 1: - // Migration from version 0 to 1 - return &Config{ - Version: 1, - Agents: cfg.Agents, - // ... copy all fields - }, nil - case 2: - // Migration from version 1 to 2 - // Example: Move or rename fields - migrated := *cfg - migrated.Version = 2 - // Apply structural changes - if cfg.SomeOldField != "" { - migrated.SomeNewField = cfg.SomeOldField - } - return &migrated, nil - default: - return nil, fmt.Errorf("unsupported migration target version: %d", toVersion) - } +func (c *configV2) Migrate() (*Config, error) { + // Apply V2→V3 structural changes here + migrated := &c.Config + migrated.Version = 3 + // Apply structural changes + return migrated, nil } ``` @@ -120,7 +114,9 @@ func LoadConfig(path string) (*Config, error) { case 1: cfg, err = loadConfigV1(data) case 2: - cfg, err = loadConfigV2(data) + cfg, err = loadConfig(data) + case 3: + cfg, err = loadConfigV3(data) default: return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } @@ -134,22 +130,22 @@ func LoadConfig(path string) (*Config, error) { Create a test in `config_migration_test.go`: ```go -func TestMigrateV1ToV2(t *testing.T) { - // Create a version 1 config - v1Config := Config{ - Version: 1, +func TestMigrateV2ToV3(t *testing.T) { + // Create a version 2 config + v2Config := Config{ + Version: 2, // ... set up test data } // Apply migration - migrated, err := applyMigration(&v1Config, 1, 2) + migrated, err := v2Config.Migrate() if err != nil { t.Fatalf("Migration failed: %v", err) } // Verify version is updated - if migrated.Version != 2 { - t.Errorf("Expected version 2, got %d", migrated.Version) + if migrated.Version != 3 { + t.Errorf("Expected version 3, got %d", migrated.Version) } // Verify data is preserved/transformed correctly @@ -164,58 +160,60 @@ func TestMigrateV1ToV2(t *testing.T) { 3. **No Data Loss**: Migrations should preserve all user settings 4. **Idempotent**: Running the same migration multiple times should be safe 5. **Auto-Save**: Migrated configs are automatically saved to update the user's file -6. **Test Thoroughly**: Test with real user config files -7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema +6. **Auto-Backup**: Before saving, the system creates a date-stamped backup of `config.json` and `.security.yml` +7. **Test Thoroughly**: Test with real user config files +8. **Update Defaults**: Keep `defaults.go` in sync with the latest schema ## Example Migration ### Scenario: Adding a new field with default value -Old config (version 1): -```json -{ - "version": 1, - "agents": { - "defaults": { - "max_tokens": 32768 - } - } -} -``` - -Migration to version 2: -```go -case 2: - migrated := *cfg - migrated.Version = 2 - - // Add new field with default value if not set - if migrated.Agents.Defaults.NewFeatureEnabled == false { - // Use default value - } - - return &migrated, nil -``` - -New config (version 2): +Old config (version 2): ```json { "version": 2, - "agents": { - "defaults": { - "max_tokens": 32768, - "new_feature_enabled": false + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4" } - } + ] +} +``` + +Migration to version 3: +```go +func (c *configV2) Migrate() (*Config, error) { + migrated := &c.Config + migrated.Version = 3 + + // Add new field with default value if not set + // ... + + return migrated, nil +} +``` + +New config (version 3): +```json +{ + "version": 3, + "model_list": [ + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "new_option": true + } + ] } ``` ## Troubleshooting ### Config Not Upgrading -- Check that `CurrentConfigVersion` is incremented -- Verify migration logic in `applyMigration()` handles the target version -- Ensure `migrateConfig()` is called in `LoadConfig()` +- Check that `CurrentVersion` is incremented +- Verify migration logic handles the target version +- Ensure `Migrate()` is called in `LoadConfig()` ### Migration Errors - Check error messages for specific migration failures @@ -227,4 +225,5 @@ New config (version 2): - Ensure all fields are copied during migration - Check that the migration doesn't overwrite values with defaults unnecessarily - Review the conversion logic in the loader functions +- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data diff --git a/docs/configuration.md b/docs/configuration.md index 9b9524692..560082e0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -479,8 +479,9 @@ This design also enables **multi-agent support** with flexible provider selectio - **Different agents, different providers**: Each agent can use its own LLM provider - **Model fallbacks**: Configure primary and fallback models for resilience -- **Load balancing**: Distribute requests across multiple endpoints +- **Load balancing**: Distribute requests across multiple endpoints or keys - **Centralized configuration**: Manage all providers in one place +- **Model enable/disable**: Use the `enabled` field to temporarily disable a model without removing its configuration #### 🔒 Security Configuration (Recommended) @@ -581,22 +582,22 @@ For complete documentation, see [`security_configuration.md`](security_configura { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -607,7 +608,9 @@ For complete documentation, see [`security_configuration.md`](security_configura } ``` -> **Security Note**: You can remove `api_key` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details. +> **Security Note**: You can remove `api_keys` fields from your config and store them in `.security.yml` instead. See [Security Configuration](#-security-configuration-recommended) above for details. +> +> **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys. #### Vendor-Specific Examples @@ -684,7 +687,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -759,13 +762,13 @@ model_list: "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -773,7 +776,7 @@ model_list: #### Migration from Legacy `providers` Config -The old `providers` configuration is **deprecated** but still supported for backward compatibility. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide. +The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide. ### Provider Architecture @@ -783,7 +786,7 @@ PicoClaw routes providers by protocol family: - **Anthropic**: Claude-native API behavior. - **Codex/OAuth**: OpenAI OAuth/token authentication route. -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu (legacy providers format) diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md index de3b70e09..54c2ee5f9 100644 --- a/docs/credential_encryption.md +++ b/docs/credential_encryption.md @@ -1,6 +1,6 @@ # Credential Encryption -PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. +PicoClaw supports encrypting `api_key`/`api_keys` values in `model_list` configuration entries. Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. --- @@ -42,6 +42,8 @@ enc://AAAA...base64... ## Supported `api_key` Formats +The same formats apply to both `api_key` (singular) and individual elements in the `api_keys` (array) field: + | Format | Example | Behaviour | |--------|---------|-----------| | Plaintext | `sk-abc123` | Used as-is | diff --git a/docs/docker.md b/docs/docker.md index 18de1ee83..6c32879a6 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -95,19 +95,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md index fd8602c68..7a57cceae 100644 --- a/docs/fr/configuration.md +++ b/docs/fr/configuration.md @@ -334,15 +334,15 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } ] } ``` #### Migration depuis l'ancienne config `providers` -L'ancienne configuration `providers` est **dépréciée** mais toujours supportée. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md). +L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md). ### Architecture des Providers diff --git a/docs/fr/docker.md b/docs/fr/docker.md index 432edb1b2..9605440bc 100644 --- a/docs/fr/docker.md +++ b/docs/fr/docker.md @@ -92,19 +92,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/fr/providers.md b/docs/fr/providers.md index 39f5cf36a..d0da81897 100644 --- a/docs/fr/providers.md +++ b/docs/fr/providers.md @@ -73,22 +73,22 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -107,7 +107,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +117,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +127,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -137,7 +137,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +147,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +161,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +189,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], "request_timeout": 300 } ``` @@ -201,7 +201,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +218,13 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +232,7 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu #### Migration depuis l'Ancienne Configuration `providers` -L'ancienne configuration `providers` est **dépréciée** mais toujours prise en charge pour la compatibilité ascendante. +L'ancienne configuration `providers` est **dépréciée** et a été supprimée dans V2. Les configs V0/V1 existantes sont auto-migrées. **Ancienne configuration (dépréciée) :** @@ -257,11 +257,12 @@ L'ancienne configuration `providers` est **dépréciée** mais toujours prise en ```json { + "version": 2, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md index da60b6052..6d6290e8a 100644 --- a/docs/ja/configuration.md +++ b/docs/ja/configuration.md @@ -335,15 +335,15 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } ] } ``` #### 旧 `providers` 設定からの移行 -旧 `providers` 設定は**非推奨**ですが後方互換性のためサポートされています。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 +旧 `providers` 設定は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。 ### Provider アーキテクチャ diff --git a/docs/ja/docker.md b/docs/ja/docker.md index 31ed17ec5..a585c5e80 100644 --- a/docs/ja/docker.md +++ b/docs/ja/docker.md @@ -94,19 +94,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/ja/providers.md b/docs/ja/providers.md index 9a53a4b69..e29c113f3 100644 --- a/docs/ja/providers.md +++ b/docs/ja/providers.md @@ -73,22 +73,22 @@ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -107,7 +107,7 @@ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +117,7 @@ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +127,18 @@ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] +} +``` + +**LiteLLM Proxy** + +```json +{ + "model_name": "lite-gpt4", + "model": "litellm/lite-gpt4", + "api_base": "http://localhost:4000/v1", + "api_keys": ["sk-..."] } ``` @@ -137,7 +148,7 @@ { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +158,7 @@ { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +172,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +200,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], "request_timeout": 300 } ``` @@ -201,7 +212,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +229,13 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +243,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック #### レガシー `providers` 設定からの移行 -旧 `providers` 設定形式は**非推奨**ですが、後方互換性のためまだサポートされています。 +旧 `providers` 設定形式は**非推奨**となり、V2 で削除されました。既存の V0/V1 設定は自動的に移行されます。 **旧設定(非推奨):** @@ -257,11 +268,12 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック ```json { + "version": 2, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -282,7 +294,7 @@ PicoClaw はプロトコルファミリーごとに Provider をルーティン - Anthropic プロトコル:Claude ネイティブ API 動作。 - Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 -これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_key`)のみで実現しています。 +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現しています。
Zhipu 設定例 diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 9d05ac599..f2a545f8f 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -50,22 +50,23 @@ The new `model_list` configuration offers several advantages: ```json { + "version": 2, "model_list": [ { "model_name": "gpt4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key", + "api_keys": ["sk-your-openai-key"], "api_base": "https://api.openai.com/v1" }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "deepseek", "model": "deepseek/deepseek-chat", - "api_key": "sk-your-deepseek-key" + "api_keys": ["sk-your-deepseek-key"] } ], "agents": { @@ -76,6 +77,8 @@ The new `model_list` configuration offers several advantages: } ``` +> **Note**: The `enabled` field can be omitted — during V1→V2 migration it is auto-inferred (models with API keys or the `local-model` name are enabled by default). For new configs, you can explicitly set `"enabled": false` to disable a model entry without removing it. + ## Protocol Prefixes The `model` field uses a protocol prefix format: `[protocol/]model-identifier` @@ -111,7 +114,8 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | `model_name` | Yes | User-facing alias for the model | | `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) | | `api_base` | No | API endpoint URL | -| `api_key` | No* | API authentication key | +| `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) | +| `enabled` | No | Whether this model entry is active. Defaults to `true` during migration for models with API keys or named `local-model`. Set to `false` to disable. | | `proxy` | No | HTTP proxy URL | | `auth_method` | No | Authentication method: `oauth`, `token` | | `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` | @@ -119,11 +123,13 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | `max_tokens_field` | No | Field name for max tokens | | `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` | -*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server. +> **Note**: `api_key` (singular) has been **removed** in V2 configs. Only `api_keys` (array) is supported. During migration from V0/V1, both `api_key` and `api_keys` are automatically merged into the new `api_keys` array. ## Load Balancing -Configure multiple endpoints for the same model to distribute load: +There are two ways to configure load balancing: + +### Option 1: Multiple API Keys in `api_keys` (Recommended) ```json { @@ -131,19 +137,45 @@ Configure multiple endpoints for the same model to distribute load: { "model_name": "gpt4", "model": "openai/gpt-5.4", - "api_key": "sk-key1", + "api_keys": ["sk-key1", "sk-key2", "sk-key3"], + "api_base": "https://api.openai.com/v1" + } + ] +} +``` + +Or via `.security.yml`: + +```yaml +model_list: + gpt4: + api_keys: + - "sk-key1" + - "sk-key2" + - "sk-key3" +``` + +### Option 2: Multiple Model Entries + +```json +{ + "model_list": [ + { + "model_name": "gpt4", + "model": "openai/gpt-5.4", + "api_keys": ["sk-key1"], "api_base": "https://api1.example.com/v1" }, { "model_name": "gpt4", "model": "openai/gpt-5.4", - "api_key": "sk-key2", + "api_keys": ["sk-key2"], "api_base": "https://api2.example.com/v1" }, { "model_name": "gpt4", "model": "openai/gpt-5.4", - "api_key": "sk-key3", + "api_keys": ["sk-key3"], "api_base": "https://api3.example.com/v1" } ] @@ -162,7 +194,7 @@ With `model_list`, adding a new provider requires zero code changes: { "model_name": "my-custom-llm", "model": "openai/my-model-v1", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "api_base": "https://api.your-provider.com/v1" } ] @@ -173,11 +205,12 @@ Just specify `openai/` as the protocol (or omit it for the default), and provide ## Backward Compatibility -During the migration period, your existing `providers` configuration will continue to work: +During the migration period, your existing V0/V1 config will be auto-migrated to V2: 1. If `model_list` is empty and `providers` has data, the system auto-converts internally -2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"` -3. All existing functionality remains unchanged +2. Both `api_key` (singular) and `api_keys` (array) in V0/V1 configs are merged into the new `api_keys` array +3. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"` +4. All existing functionality remains unchanged ## Migration Checklist @@ -212,7 +245,7 @@ unknown protocol "xxx" in model "xxx/model-name" api_key or api_base is required for HTTP-based protocol "xxx" ``` -**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers. +**Solution**: Provide `api_keys` and/or `api_base` for HTTP-based providers. ## Need Help? diff --git a/docs/my/docker.md b/docs/my/docker.md index 8fe1aed8c..2f9cac3fd 100644 --- a/docs/my/docker.md +++ b/docs/my/docker.md @@ -91,19 +91,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/providers.md b/docs/providers.md index c6d442a3b..43cba55b5 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -79,22 +79,22 @@ This design also enables **multi-agent support** with flexible provider selectio { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -117,7 +117,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "voice-gemini", "model": "gemini/gemini-2.5-flash", - "api_key": "your-gemini-key" + "api_keys": ["your-gemini-key"] } ], "voice": { @@ -140,7 +140,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -150,7 +150,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -160,7 +160,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -170,7 +170,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "glm-4.7", "model": "openai/glm-4.7", - "api_key": "your-z.ai-key" + "api_keys": ["your-z.ai-key"], "api_base": "https://api.z.ai/api/coding/paas/v4" } ``` @@ -181,7 +181,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -191,7 +191,7 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -205,7 +205,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -233,7 +233,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], "request_timeout": 300 } ``` @@ -245,7 +245,7 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -259,7 +259,7 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns { "model_name": "glm-4.7", "model": "openai/glm-4.7", - "api_key": "your-zhipu-api-key", + "api_keys": ["your-zhipu-api-key"], "api_base": "https://api.z.ai/api/coding/paas/v4" } ``` @@ -277,13 +277,13 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -302,17 +302,17 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a "model_name": "qwen-main", "model": "openai/qwen3.5:cloud", "api_base": "https://api.example.com/v1", - "api_key": "sk-main" + "api_keys": ["sk-main"] }, { "model_name": "deepseek-backup", "model": "deepseek/deepseek-chat", - "api_key": "sk-backup-1" + "api_keys": ["sk-backup-1"] }, { "model_name": "gemini-backup", "model": "gemini/gemini-2.5-flash", - "api_key": "sk-backup-2" + "api_keys": ["sk-backup-2"] } ], "agents": { @@ -330,7 +330,7 @@ If you use key-level failover for the same model, PicoClaw can chain through add #### Migration from Legacy `providers` Config -The old `providers` configuration is **deprecated** but still supported for backward compatibility. +The old `providers` configuration is **deprecated** and has been removed in V2. Existing V0/V1 configs are auto-migrated. **Old Config (deprecated):** @@ -355,11 +355,12 @@ The old `providers` configuration is **deprecated** but still supported for back ```json { + "version": 2, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md index cb836ce0f..27cd6d21f 100644 --- a/docs/pt-br/configuration.md +++ b/docs/pt-br/configuration.md @@ -335,15 +335,15 @@ Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará ro ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } ] } ``` #### Migração da Configuração Legada `providers` -A configuração antiga `providers` está **depreciada** mas ainda é suportada. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md). +A configuração antiga `providers` está **depreciada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md). ### Arquitetura de Providers diff --git a/docs/pt-br/docker.md b/docs/pt-br/docker.md index bac48954b..a17dc64ec 100644 --- a/docs/pt-br/docker.md +++ b/docs/pt-br/docker.md @@ -92,19 +92,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md index 0f7a4b5a1..c7c6305e2 100644 --- a/docs/pt-br/providers.md +++ b/docs/pt-br/providers.md @@ -73,22 +73,22 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -107,7 +107,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +117,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +127,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -137,7 +137,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +147,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +161,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +189,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], "request_timeout": 300 } ``` @@ -201,7 +201,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +218,13 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +232,7 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará #### Migração da Configuração Legacy `providers` -A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade retroativa. +A configuração antiga `providers` está **descontinuada** e foi removida no V2. Configs V0/V1 existentes são auto-migradas. **Configuração Antiga (descontinuada):** @@ -257,11 +257,12 @@ A configuração antiga `providers` está **descontinuada** mas ainda é suporta ```json { + "version": 2, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -282,7 +283,7 @@ O PicoClaw roteia provedores por família de protocolo: - Protocolo Anthropic: Comportamento nativo da API Claude. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. -Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_key`). +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`).
Zhipu diff --git a/docs/security_configuration.md b/docs/security_configuration.md index f4fe0e304..311c1790e 100644 --- a/docs/security_configuration.md +++ b/docs/security_configuration.md @@ -401,7 +401,7 @@ The pattern is: `PICOCLAW_
__` with underscores separating p 3. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml` 4. **Use different keys** for different environments (dev, staging, production) 5. **Rotate keys regularly** and update `.security.yml` -6. **Backup securely**: Encrypt backups containing `.security.yml` +6. **Backup securely**: Encrypt backups containing `.security.yml`. Note that config migrations automatically create date-stamped backups (e.g., `config.json.20260330.bak` and `.security.yml.20260330.bak`) 7. **Review access**: Ensure only authorized users have read access to the file ## API @@ -444,7 +444,7 @@ Returns the path to `.security.yml` relative to the config file. ```json { - "version": 1, + "version": 2, "agents": { "defaults": { "workspace": "~/picoclaw-workspace", @@ -557,6 +557,8 @@ go test ./pkg/config -run TestSecurityConfig ### Step 1: Backup your config +The system automatically creates a date-stamped backup before saving a migrated config (e.g., `config.json.20260330.bak` and `.security.yml.20260330.bak`). If you prefer a manual backup: + ```bash cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup ``` @@ -597,9 +599,11 @@ Test your models and channels to ensure everything works correctly. ### Step 8: Clean up (optional) -If everything works, you can delete the backup: +If everything works, you can delete the backups: ```bash rm ~/.picoclaw/config.json.backup +# Also remove auto-generated date-stamped backups if desired: +rm ~/.picoclaw/config.json.20*.bak ~/.picoclaw/.security.yml.20*.bak ``` ## Advanced: Encrypted API Keys diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md index b75215c3f..56eb8f557 100644 --- a/docs/vi/configuration.md +++ b/docs/vi/configuration.md @@ -335,15 +335,15 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ ```json { "model_list": [ - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, - { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, + { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } ] } ``` #### Di Chuyển Từ Cấu Hình `providers` Cũ -Cấu hình `providers` cũ đã **bị deprecated** nhưng vẫn được hỗ trợ. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md). +Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md). ### Kiến Trúc Provider diff --git a/docs/vi/docker.md b/docs/vi/docker.md index eddc20a75..e6bc74b1a 100644 --- a/docs/vi/docker.md +++ b/docs/vi/docker.md @@ -92,19 +92,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/vi/providers.md b/docs/vi/providers.md index 09b51c56b..ffd992645 100644 --- a/docs/vi/providers.md +++ b/docs/vi/providers.md @@ -73,22 +73,22 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -107,7 +107,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -117,7 +117,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -127,7 +127,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -137,7 +137,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -147,7 +147,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -161,7 +161,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -189,7 +189,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], "request_timeout": 300 } ``` @@ -201,7 +201,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -218,13 +218,13 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -232,7 +232,7 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ #### Di Chuyển Từ Cấu Hình Legacy `providers` -Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được hỗ trợ để tương thích ngược. +Cấu hình `providers` cũ đã **bị deprecated** và đã được loại bỏ trong V2. Các cấu hình V0/V1 hiện có sẽ được tự động migrate. **Cấu hình cũ (ngừng hỗ trợ):** @@ -257,11 +257,12 @@ Cấu hình `providers` cũ đã **ngừng hỗ trợ** nhưng vẫn được h ```json { + "version": 2, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { @@ -282,7 +283,7 @@ PicoClaw định tuyến provider theo họ giao thức: - Giao thức Anthropic: Hành vi API native của Claude. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. -Điều này giữ runtime nhẹ trong khi làm cho backend tương thích OpenAI mới chủ yếu là thao tác cấu hình (`api_base` + `api_key`). +Điều này giữ runtime nhẹ trong khi làm cho backend tương thích OpenAI mới chủ yếu là thao tác cấu hình (`api_base` + `api_keys`).
Zhipu diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index c27a439f0..d4366b6a8 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -386,22 +386,22 @@ Agent 读取 HEARTBEAT.md { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -421,7 +421,7 @@ Agent 读取 HEARTBEAT.md { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -434,7 +434,7 @@ Agent 读取 HEARTBEAT.md { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -447,7 +447,7 @@ Agent 读取 HEARTBEAT.md { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -460,7 +460,7 @@ Agent 读取 HEARTBEAT.md { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -473,7 +473,7 @@ Agent 读取 HEARTBEAT.md { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] } ``` @@ -485,7 +485,7 @@ Agent 读取 HEARTBEAT.md { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -514,7 +514,7 @@ Agent 读取 HEARTBEAT.md "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -533,13 +533,13 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -547,7 +547,7 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell #### 从旧版 `providers` 配置迁移 -旧版 `providers` 配置**已废弃**,但仍向后兼容。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 +旧版 `providers` 配置**已废弃**,V2 中已移除。现有 V0/V1 配置会自动迁移。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。 ### Provider 架构 diff --git a/docs/zh/docker.md b/docs/zh/docker.md index 8aed1e86b..f840290a7 100644 --- a/docs/zh/docker.md +++ b/docs/zh/docker.md @@ -94,19 +94,19 @@ picoclaw onboard { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key", + "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "your-api-key", + "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "your-anthropic-key" + "api_keys": ["your-anthropic-key"] } ], "tools": { diff --git a/docs/zh/providers.md b/docs/zh/providers.md index 4bcf7087e..d19dcdd89 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -75,22 +75,22 @@ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-your-api-key" + "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-your-openai-key" + "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6", - "api_key": "sk-ant-your-key" + "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-zhipu-key" + "api_keys": ["your-zhipu-key"] } ], "agents": { @@ -113,7 +113,7 @@ { "model_name": "voice-gemini", "model": "gemini/gemini-2.5-flash", - "api_key": "your-gemini-key" + "api_keys": ["your-gemini-key"] } ], "voice": { @@ -136,7 +136,7 @@ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -146,7 +146,7 @@ { "model_name": "ark-code-latest", "model": "volcengine/ark-code-latest", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -156,7 +156,7 @@ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ``` @@ -166,7 +166,7 @@ { "model_name": "deepseek-chat", "model": "deepseek/deepseek-chat", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -190,7 +190,7 @@ { "model_name": "claude-opus-4-6", "model": "anthropic-messages/claude-opus-4-6", - "api_key": "sk-ant-your-key", + "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } ``` @@ -218,7 +218,7 @@ "model_name": "my-custom-model", "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", - "api_key": "sk-...", + "api_keys": ["sk-..."], "request_timeout": 300 } ``` @@ -230,7 +230,7 @@ "model_name": "lite-gpt4", "model": "litellm/lite-gpt4", "api_base": "http://localhost:4000/v1", - "api_key": "sk-..." + "api_keys": ["sk-..."] } ``` @@ -247,13 +247,13 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", - "api_key": "sk-key1" + "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", - "api_key": "sk-key2" + "api_keys": ["sk-key2"] } ] } @@ -272,17 +272,17 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_name": "qwen-main", "model": "openai/qwen3.5:cloud", "api_base": "https://api.example.com/v1", - "api_key": "sk-main" + "api_keys": ["sk-main"] }, { "model_name": "deepseek-backup", "model": "deepseek/deepseek-chat", - "api_key": "sk-backup-1" + "api_keys": ["sk-backup-1"] }, { "model_name": "gemini-backup", "model": "gemini/gemini-2.5-flash", - "api_key": "sk-backup-2" + "api_keys": ["sk-backup-2"] } ], "agents": { @@ -300,7 +300,7 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l #### 从旧的 `providers` 配置迁移 -旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 +旧的 `providers` 配置格式**已弃用**,V2 中已移除。现有 V0/V1 配置会自动迁移。 **旧配置(已弃用):** @@ -325,11 +325,12 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l ```json { + "version": 2, "model_list": [ { "model_name": "glm-4.7", "model": "zhipu/glm-4.7", - "api_key": "your-key" + "api_keys": ["your-key"] } ], "agents": { From 275c1012f1d4100064404ad4c31011da48cd7ec9 Mon Sep 17 00:00:00 2001 From: Cytown Date: Mon, 30 Mar 2026 18:00:18 +0800 Subject: [PATCH 41/72] make gateway reload use new loglevel (#2155) --- pkg/gateway/gateway.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index c563a99a2..209c7069b 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -104,11 +104,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error logger.Fatalf("config pre-check failed: %v", err) } - logger.SetLevelFromString(cfg.Gateway.LogLevel) - + // Debug mode permanently overrides the config log level to DEBUG. if debug { logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") + } else { + logger.SetLevelFromString(cfg.Gateway.LogLevel) + logger.Infof("Log level set to %q", cfg.Gateway.LogLevel) } provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) @@ -191,7 +193,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error logger.Warn("Config reload skipped: another reload is in progress") continue } - err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) + err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug) if err != nil { logger.Errorf("Config reload failed: %v", err) } @@ -208,7 +210,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error runningServices.reloading.Store(false) continue } - err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) + err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug) if err != nil { logger.Errorf("Manual reload failed: %v", err) } else { @@ -233,9 +235,10 @@ func executeReload( runningServices *services, msgBus *bus.MessageBus, allowEmptyStartup bool, + debug bool, ) error { defer runningServices.reloading.Store(false) - return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup) + return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) } func createStartupProvider( @@ -403,6 +406,7 @@ func handleConfigReload( runningServices *services, msgBus *bus.MessageBus, allowEmptyStartup bool, + debug bool, ) error { logger.Info("🔄 Config file changed, reloading...") @@ -451,6 +455,14 @@ func handleConfigReload( } logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)") + + // Debug mode permanently overrides the config log level to DEBUG. + if !debug { + // Update log level last so that reload-related info/warn logs above are not suppressed. + logger.SetLevelFromString(newCfg.Gateway.LogLevel) + logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel) + } + return nil } From 9440bebca6f8daa683c8b5519809840ebb65daa1 Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:58:12 +0800 Subject: [PATCH 42/72] utils: anchor date retry-after to response date and cap delay --- pkg/utils/http_retry.go | 24 +++++++++++++++---- pkg/utils/http_retry_test.go | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go index 812271290..fcd8f50f5 100644 --- a/pkg/utils/http_retry.go +++ b/pkg/utils/http_retry.go @@ -11,6 +11,7 @@ import ( const maxRetries = 3 var retryDelayUnit = time.Second +var maxRetrySleepDuration = 1 * time.Minute func shouldRetry(statusCode int) bool { return statusCode == http.StatusTooManyRequests || @@ -51,27 +52,40 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration { fallback := retryDelayUnit * time.Duration(attempt+1) if resp == nil || resp.StatusCode != http.StatusTooManyRequests { - return fallback + return clampRetryDelay(fallback) } retryAfter := resp.Header.Get("Retry-After") if retryAfter == "" { - return fallback + return clampRetryDelay(fallback) } if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 { - return time.Duration(seconds) * time.Second + return clampRetryDelay(time.Duration(seconds) * time.Second) } if when, err := http.ParseTime(retryAfter); err == nil { delay := time.Until(when) + if serverDate, err := http.ParseTime(resp.Header.Get("Date")); err == nil { + delay = when.Sub(serverDate) + } if delay < 0 { return 0 } - return delay + return clampRetryDelay(delay) } - return fallback + return clampRetryDelay(fallback) +} + +func clampRetryDelay(delay time.Duration) time.Duration { + if delay <= 0 { + return 0 + } + if delay > maxRetrySleepDuration { + return maxRetrySleepDuration + } + return delay } func sleepWithCtx(ctx context.Context, d time.Duration) error { diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go index 918245b57..1e5ac4064 100644 --- a/pkg/utils/http_retry_test.go +++ b/pkg/utils/http_retry_test.go @@ -279,3 +279,49 @@ func TestDoRequestWithRetry_Delay(t *testing.T) { assert.GreaterOrEqual(t, delays[2], time.Millisecond) } + +func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) { + maxRetrySleepDuration = time.Minute + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + serverDate := time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC) + retryAfterAt := serverDate.Add(10 * time.Second) + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{retryAfterAt.Format(http.TimeFormat)}, + "Date": []string{serverDate.Format(http.TimeFormat)}, + }, + } + + assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0)) +} + +func TestRetryDelayForAttempt_DateRetryAfterInvalidDateFallsBackSafely(t *testing.T) { + maxRetrySleepDuration = time.Minute + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC).Format(http.TimeFormat)}, + "Date": []string{"invalid-date"}, + }, + } + + assert.Equal(t, time.Duration(0), retryDelayForAttempt(resp, 0)) +} + +func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) { + maxRetrySleepDuration = 2 * time.Second + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{"999999"}, + }, + } + + assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0)) +} From 345d4fddc9b2a1d96b366b0ebd3fe6421eb09d6c Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:02:16 +0800 Subject: [PATCH 43/72] utils: make retry-after numeric clamp overflow-safe --- pkg/utils/http_retry.go | 16 +++++++++-- pkg/utils/http_retry_test.go | 54 ++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go index fcd8f50f5..a808759ae 100644 --- a/pkg/utils/http_retry.go +++ b/pkg/utils/http_retry.go @@ -60,8 +60,8 @@ func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration { return clampRetryDelay(fallback) } - if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 { - return clampRetryDelay(time.Duration(seconds) * time.Second) + if delay, ok := numericRetryAfterDelay(retryAfter); ok { + return delay } if when, err := http.ParseTime(retryAfter); err == nil { @@ -78,6 +78,18 @@ func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration { return clampRetryDelay(fallback) } +func numericRetryAfterDelay(retryAfter string) (time.Duration, bool) { + seconds, err := strconv.ParseInt(retryAfter, 10, 64) + if err != nil || seconds < 0 { + return 0, false + } + maxSeconds := int64(maxRetrySleepDuration / time.Second) + if seconds > maxSeconds { + return maxRetrySleepDuration, true + } + return clampRetryDelay(time.Duration(seconds) * time.Second), true +} + func clampRetryDelay(delay time.Duration) time.Duration { if delay <= 0 { return 0 diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go index 1e5ac4064..4d6021ff7 100644 --- a/pkg/utils/http_retry_test.go +++ b/pkg/utils/http_retry_test.go @@ -297,19 +297,43 @@ func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0)) } -func TestRetryDelayForAttempt_DateRetryAfterInvalidDateFallsBackSafely(t *testing.T) { - maxRetrySleepDuration = time.Minute +func TestRetryDelayForAttempt_DateRetryAfterInvalidOrMissingDateFallsBackSafely(t *testing.T) { + maxRetrySleepDuration = 30 * time.Second t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) - resp := &http.Response{ - StatusCode: http.StatusTooManyRequests, - Header: http.Header{ - "Retry-After": []string{time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC).Format(http.TimeFormat)}, - "Date": []string{"invalid-date"}, + retryAfterAt := time.Now().UTC().Add(3 * time.Second).Format(http.TimeFormat) + testcases := []struct { + name string + header http.Header + }{ + { + name: "invalid-date-header", + header: http.Header{ + "Retry-After": []string{retryAfterAt}, + "Date": []string{"invalid-date"}, + }, + }, + { + name: "missing-date-header", + header: http.Header{ + "Retry-After": []string{retryAfterAt}, + }, }, } - assert.Equal(t, time.Duration(0), retryDelayForAttempt(resp, 0)) + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: tc.header, + } + + delay := retryDelayForAttempt(resp, 0) + assert.Greater(t, delay, time.Duration(0)) + assert.GreaterOrEqual(t, delay, 1500*time.Millisecond) + assert.LessOrEqual(t, delay, 5*time.Second) + }) + } } func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) { @@ -325,3 +349,17 @@ func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) { assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0)) } + +func TestRetryDelayForAttempt_RetryAfterNumericOverflowStillCaps(t *testing.T) { + maxRetrySleepDuration = 2 * time.Second + t.Cleanup(func() { maxRetrySleepDuration = time.Minute }) + + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{ + "Retry-After": []string{"9223372036854775807"}, + }, + } + + assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0)) +} From 711685192c6e63e0a007114c68fe3dcf4585532f Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:08:21 +0800 Subject: [PATCH 44/72] utils: gofumpt http retry formatting --- pkg/utils/http_retry.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go index a808759ae..514f9781b 100644 --- a/pkg/utils/http_retry.go +++ b/pkg/utils/http_retry.go @@ -10,8 +10,10 @@ import ( const maxRetries = 3 -var retryDelayUnit = time.Second -var maxRetrySleepDuration = 1 * time.Minute +var ( + retryDelayUnit = time.Second + maxRetrySleepDuration = 1 * time.Minute +) func shouldRetry(statusCode int) bool { return statusCode == http.StatusTooManyRequests || From b8327462f9be241082508c398cc6e759c4295bd1 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Tue, 31 Mar 2026 00:43:35 +0800 Subject: [PATCH 45/72] feat: add first-time tour guide for new users - Add tour guide component with floating bubbles - Guide users through: Welcome -> Configure Models -> Start Gateway -> View Docs - Use localStorage to persist tour state - Support i18n (Chinese and English) - Highlight target elements with spotlight mask - Allow skipping tour at any time --- web/frontend/src/components/app-header.tsx | 10 +- web/frontend/src/components/app-layout.tsx | 2 + web/frontend/src/components/app-sidebar.tsx | 1 + .../src/components/tour/tour-guide.tsx | 242 ++++++++++++++++++ web/frontend/src/i18n/locales/en.json | 22 ++ web/frontend/src/i18n/locales/zh.json | 22 ++ web/frontend/src/store/index.ts | 1 + web/frontend/src/store/tour.ts | 69 +++++ 8 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 web/frontend/src/components/tour/tour-guide.tsx create mode 100644 web/frontend/src/store/tour.ts diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index 4f0688008..fa1b5a488 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -163,6 +163,7 @@ export function AppHeader() { variant="destructive" size="icon-sm" className="size-8" + data-tour="gateway-button" onClick={handleGatewayToggle} disabled={gwLoading} aria-label={t("header.gateway.action.stop")} @@ -178,6 +179,7 @@ export function AppHeader() { isStarting || isRestarting || isStopping ? "secondary" : "default" } size="sm" + data-tour="gateway-button" className={`h-8 gap-2 px-3 ${ isStopped ? "bg-green-500 text-white hover:bg-green-600" : "" }`} @@ -209,7 +211,13 @@ export function AppHeader() { /> {/* Docs Link */} -
+ ) diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 18bc9f092..1ba255693 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -199,6 +199,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/web/frontend/src/components/tour/tour-guide.tsx b/web/frontend/src/components/tour/tour-guide.tsx new file mode 100644 index 000000000..cc1e6e3a1 --- /dev/null +++ b/web/frontend/src/components/tour/tour-guide.tsx @@ -0,0 +1,242 @@ +import { + IconBook, + IconChevronLeft, + IconChevronRight, +} from "@tabler/icons-react" +import { useAtom } from "jotai" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" +import { + tourAtom, + tourCurrentStepAtom, + tourIsActiveAtom, + type TourStep, + useTourActions, +} from "@/store/tour" +import { cn } from "@/lib/utils" + +interface TourStepConfig { + title: string + description: string + targetSelector?: string + position: "top" | "bottom" | "left" | "right" + icon?: React.ReactNode + offsetY?: number +} + +export function TourGuide() { + const { t } = useTranslation() + const [tourState] = useAtom(tourAtom) + const [, setCurrentStep] = useAtom(tourCurrentStepAtom) + const [, setIsActive] = useAtom(tourIsActiveAtom) + const { goToNextStep, goToPrevStep } = useTourActions() + + if (!tourState.isActive || tourState.currentStep === "completed") { + return null + } + + const steps: Record = { + welcome: { + title: t("tour.welcome.title"), + description: t("tour.welcome.description"), + position: "bottom", + }, + models: { + title: t("tour.models.title"), + description: t("tour.models.description"), + targetSelector: "[data-tour='models-nav']", + position: "right", + }, + gateway: { + title: t("tour.gateway.title"), + description: t("tour.gateway.description"), + targetSelector: "[data-tour='gateway-button']", + position: "left", + offsetY: 60, + }, + docs: { + title: t("tour.docs.title"), + description: t("tour.docs.description"), + targetSelector: "[data-tour='docs-button']", + position: "left", + icon: , + offsetY: 60, + }, + completed: { + title: "", + description: "", + position: "bottom", + }, + } + + const currentConfig = steps[tourState.currentStep] + const stepOrder: TourStep[] = [ + "welcome", + "models", + "gateway", + "docs", + "completed", + ] + const currentStepIndex = stepOrder.indexOf(tourState.currentStep) + const totalSteps = stepOrder.length - 1 + + const handleNext = () => { + const nextStep = goToNextStep(tourState.currentStep) + setCurrentStep(nextStep) + if (nextStep === "completed") { + setIsActive(false) + } + } + + const handlePrev = () => { + const prevStep = goToPrevStep(tourState.currentStep) + setCurrentStep(prevStep) + } + + const handleSkip = () => { + setCurrentStep("completed") + setIsActive(false) + } + + const getTargetElement = () => { + if (!currentConfig.targetSelector) return null + return document.querySelector(currentConfig.targetSelector) + } + + const targetElement = getTargetElement() + + const getPopoverPosition = () => { + if (!targetElement) { + return { + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + } + } + + const rect = targetElement.getBoundingClientRect() + const offset = 12 + const offsetY = currentConfig.offsetY ?? 0 + + switch (currentConfig.position) { + case "top": + return { + top: rect.top - offset, + left: rect.left + rect.width / 2, + transform: "translate(-50%, -100%)", + } + case "bottom": + return { + top: rect.bottom + offset, + left: rect.left + rect.width / 2, + transform: "translateX(-50%)", + } + case "left": + return { + top: rect.top + rect.height / 2 + offsetY, + left: rect.left - offset, + transform: "translate(-100%, -50%)", + } + case "right": + return { + top: rect.top + rect.height / 2 + offsetY, + left: rect.right + offset, + transform: "translateY(-50%)", + } + default: + return { + top: rect.bottom + offset, + left: rect.left + rect.width / 2, + transform: "translateX(-50%)", + } + } + } + + const position = getPopoverPosition() + const isCentered = !targetElement + + return ( + <> + {targetElement ? ( +
+ ) : ( +
+ )} + + {targetElement && ( +
+ )} + +
+
+ {currentConfig.icon} +

{currentConfig.title}

+
+ +

+ {currentConfig.description} +

+ +
+
+ {currentStepIndex + 1} / {totalSteps} +
+ +
+ {currentStepIndex > 0 && ( + + )} + +
+
+ + {currentStepIndex < totalSteps - 1 && ( + + )} +
+ + ) +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 9d170a4c8..69e256758 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -550,5 +550,27 @@ "clear": "Clear logs", "empty": "Waiting for logs..." } + }, + "tour": { + "skip": "Skip tour", + "prev": "Previous", + "next": "Next", + "finish": "Finish", + "welcome": { + "title": "Welcome to PicoClaw", + "description": "PicoClaw is a powerful AI assistant platform. Let's take a few seconds to help you complete the basic setup." + }, + "models": { + "title": "Configure Models", + "description": "Click the \"Models\" menu on the left to configure API keys for AI providers. Only configured models can be used for chat." + }, + "gateway": { + "title": "Start Gateway", + "description": "After configuring models, click the \"Start Gateway\" button at the top to begin chatting with AI." + }, + "docs": { + "title": "View Documentation", + "description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs." + } } } diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index b214753ca..e7aca1918 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -550,5 +550,27 @@ "clear": "清空日志", "empty": "等待日志中..." } + }, + "tour": { + "skip": "跳过引导", + "prev": "上一步", + "next": "下一步", + "finish": "完成", + "welcome": { + "title": "欢迎使用 PicoClaw", + "description": "PicoClaw 是一个强大的 AI 助手平台。让我们花几秒钟时间,帮您完成基础配置。" + }, + "models": { + "title": "配置模型", + "description": "点击左侧「模型」菜单,为 AI 服务商配置 API Key。只有配置好的模型才能用于对话。" + }, + "gateway": { + "title": "启动服务", + "description": "配置好模型后,点击顶部的「启动服务」按钮,即可开始与 AI 对话。" + }, + "docs": { + "title": "查看文档", + "description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。" + } } } diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts index d377cdace..a13b7b161 100644 --- a/web/frontend/src/store/index.ts +++ b/web/frontend/src/store/index.ts @@ -1,2 +1,3 @@ export * from "./gateway" export * from "./chat" +export * from "./tour" diff --git a/web/frontend/src/store/tour.ts b/web/frontend/src/store/tour.ts new file mode 100644 index 000000000..40fe697e2 --- /dev/null +++ b/web/frontend/src/store/tour.ts @@ -0,0 +1,69 @@ +import { atom } from "jotai" +import { atomWithStorage } from "jotai/utils" + +export type TourStep = "welcome" | "models" | "gateway" | "docs" | "completed" + +export interface TourState { + currentStep: TourStep + isActive: boolean +} + +const STORAGE_KEY = "picoclaw-tour-state" + +const DEFAULT_TOUR_STATE: TourState = { + currentStep: "welcome", + isActive: true, +} + +export const tourAtom = atomWithStorage( + STORAGE_KEY, + DEFAULT_TOUR_STATE, +) + +export const tourIsActiveAtom = atom( + (get) => get(tourAtom).isActive, + (get, set, isActive: boolean) => { + set(tourAtom, { ...get(tourAtom), isActive }) + }, +) + +export const tourCurrentStepAtom = atom( + (get) => get(tourAtom).currentStep, + (get, set, step: TourStep) => { + set(tourAtom, { ...get(tourAtom), currentStep: step }) + }, +) + +export function useTourActions() { + const goToNextStep = (currentStep: TourStep): TourStep => { + const steps: TourStep[] = [ + "welcome", + "models", + "gateway", + "docs", + "completed", + ] + const currentIndex = steps.indexOf(currentStep) + if (currentIndex < steps.length - 1) { + return steps[currentIndex + 1] + } + return "completed" + } + + const goToPrevStep = (currentStep: TourStep): TourStep => { + const steps: TourStep[] = [ + "welcome", + "models", + "gateway", + "docs", + "completed", + ] + const currentIndex = steps.indexOf(currentStep) + if (currentIndex > 0) { + return steps[currentIndex - 1] + } + return currentStep + } + + return { goToNextStep, goToPrevStep } +} From 2d8556205faaf24059f60c70f118230e1ffc0095 Mon Sep 17 00:00:00 2001 From: Mauro Date: Tue, 31 Mar 2026 04:46:41 +0200 Subject: [PATCH 46/72] feat(telegram): include quoted reply context and media in inbound turns (#2200) --- pkg/channels/telegram/telegram.go | 136 ++++++++++++++++++ pkg/channels/telegram/telegram_test.go | 183 +++++++++++++++++++++++++ 2 files changed, 319 insertions(+) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f64a8f79b..f76d625fb 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -660,6 +660,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } + if message.ReplyToMessage != nil { + quotedMedia := quotedTelegramMediaRefs( + message.ReplyToMessage, + func(fileID, ext, filename string) string { + localPath := c.downloadFile(ctx, fileID, ext) + if localPath == "" { + return "" + } + return storeMedia(localPath, filename) + }, + ) + if len(quotedMedia) > 0 { + mediaPaths = append(quotedMedia, mediaPaths...) + } + content = c.prependTelegramQuotedReply(content, message.ReplyToMessage) + } + // For forum topics, embed the thread ID as "chatID/threadID" so replies // route to the correct topic and each topic gets its own session. // Only forum groups (IsForum) are handled; regular group reply threads @@ -693,6 +710,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + if message.ReplyToMessage != nil { + metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID) + } // Set parent_peer metadata for per-topic agent binding. if message.Chat.IsForum && threadID != 0 { @@ -713,6 +733,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return nil } +func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string { + quoted := strings.TrimSpace(telegramQuotedContent(reply)) + if quoted == "" { + return content + } + + author := telegramQuotedAuthor(reply) + role := c.telegramQuotedRole(reply) + if strings.TrimSpace(content) == "" { + return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted) + } + return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content) +} + +func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string { + if message == nil { + return "unknown" + } + + if message.From != nil { + if !message.From.IsBot { + return "user" + } + if c.isOwnBotUser(message.From) { + return "assistant" + } + return "bot" + } + + if message.SenderChat != nil { + return "chat" + } + + return "unknown" +} + +func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool { + if c == nil || c.bot == nil || user == nil || !user.IsBot { + return false + } + + if botID := c.bot.ID(); botID != 0 && user.ID == botID { + return true + } + + botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@") + if botUsername == "" { + return false + } + return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername) +} + +func telegramQuotedAuthor(message *telego.Message) string { + if message == nil || message.From == nil { + return "unknown" + } + if username := strings.TrimSpace(message.From.Username); username != "" { + return username + } + if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" { + return firstName + } + return "unknown" +} + +func telegramQuotedContent(message *telego.Message) string { + if message == nil { + return "" + } + + var parts []string + if text := strings.TrimSpace(message.Text); text != "" { + parts = append(parts, text) + } + if caption := strings.TrimSpace(message.Caption); caption != "" { + parts = append(parts, caption) + } + switch { + case len(message.Photo) > 0: + parts = append(parts, "[image: photo]") + } + switch { + case message.Voice != nil: + parts = append(parts, "[voice]") + case message.Audio != nil: + parts = append(parts, "[audio]") + } + if message.Document != nil { + parts = append(parts, "[file]") + } + + return strings.Join(parts, "\n") +} + +func quotedTelegramMediaRefs( + message *telego.Message, + resolve func(fileID, ext, filename string) string, +) []string { + if message == nil || resolve == nil { + return nil + } + + var refs []string + if message.Voice != nil { + if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" { + refs = append(refs, ref) + } + } + if message.Audio != nil { + if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" { + refs = append(refs, ref) + } + } + return refs +} + func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index fd189d9a7..1be51abdc 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" @@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response { return &ta.Response{Ok: true, Result: b} } +func successUserResponse(t *testing.T, user *telego.User) *ta.Response { + t.Helper() + b, err := json.Marshal(user) + require.NoError(t, err) + return &ta.Response{Ok: true, Result: b} +} + // newTestChannel creates a TelegramChannel with a mocked bot for unit testing. func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { return newTestChannelWithConstructor(t, caller, &stubConstructor{}) @@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { assert.Empty(t, inbound.Metadata["parent_peer_id"]) } +func assertHandleMessageQuotedUserReply( + t *testing.T, + chatID int64, + messageID int, + userID int64, + userName string, + userText string, + replyMessageID int, + replyText string, + replyCaption string, + replyAuthorID int64, + replyAuthorName string, + expectedContent string, +) { + t.Helper() + + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: userText, + MessageID: messageID, + Chat: telego.Chat{ + ID: chatID, + Type: "private", + }, + From: &telego.User{ + ID: userID, + FirstName: userName, + }, + ReplyToMessage: &telego.Message{ + MessageID: replyMessageID, + Text: replyText, + Caption: replyCaption, + From: &telego.User{ + ID: replyAuthorID, + FirstName: replyAuthorName, + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"]) + assert.Equal(t, expectedContent, inbound.Content) +} + +func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) { + assertHandleMessageQuotedUserReply( + t, + 456, + 21, + 11, + "Alice", + "follow up", + 99, + "old context", + "", + 12, + "Bob", + "[quoted user message from Bob]: old context\n\nfollow up", + ) +} + +func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) { + assertHandleMessageQuotedUserReply( + t, + 789, + 22, + 13, + "Carol", + "answer this", + 100, + "", + "caption context", + 14, + "Dave", + "[quoted user message from Dave]: caption context\n\nanswer this", + ) +} + +func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) { + messageBus := bus.NewMessageBus() + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + if strings.Contains(url, "getMe") { + return successUserResponse(t, &telego.User{ + ID: 42, + IsBot: true, + FirstName: "Pico", + Username: "afjcjsbx_picoclaw_bot", + }), nil + } + t.Fatalf("unexpected API call: %s", url) + return nil, nil + }, + } + ch := newTestChannel(t, caller) + ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil) + ch.ctx = context.Background() + + msg := &telego.Message{ + Text: "ti ricordi questo file?", + MessageID: 23, + Chat: telego.Chat{ + ID: 999, + Type: "private", + }, + From: &telego.User{ + ID: 15, + FirstName: "Eve", + }, + ReplyToMessage: &telego.Message{ + MessageID: 101, + Text: "Fatto! Ho creato il file notizie_2026_03_28.md", + From: &telego.User{ + ID: 42, + IsBot: true, + FirstName: "Pico", + Username: "afjcjsbx_picoclaw_bot", + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"]) + assert.Equal( + t, + "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?", + inbound.Content, + ) +} + +func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) { + msg := &telego.Message{ + Caption: "listen to this", + Voice: &telego.Voice{ + FileID: "voice-file", + }, + } + + assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg)) +} + +func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) { + msg := &telego.Message{ + Voice: &telego.Voice{FileID: "voice-file"}, + Audio: &telego.Audio{FileID: "audio-file"}, + } + + var calls []string + refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string { + calls = append(calls, fileID+"|"+ext+"|"+filename) + return "ref://" + filename + }) + + assert.Equal( + t, + []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"}, + calls, + ) + assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs) +} + func TestHandleMessage_EmptyContent_Ignored(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ From 6c0798ca3f36a42a4a4edca50c47c3a92ffd60cc Mon Sep 17 00:00:00 2001 From: DimonB Date: Tue, 31 Mar 2026 06:07:32 +0300 Subject: [PATCH 47/72] feat(channels): make Channel.Send return delivered message IDs (#2190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error) and MediaSender.SendMedia similarly, so callers can capture platform message IDs for threading, reactions, and history annotation. Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID), Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil because UploadFileV2 does not expose the posted message timestamp in its response. All other adapters return nil IDs. preSend and sendWithRetry in manager.go updated to propagate ([]string, bool). README examples updated for both English and Chinese docs. * style: apply golangci-lint fixes (golines) * docs: fix Send migration guide — restore old error-only signature in before/after example --- pkg/agent/loop_test.go | 22 ++--- pkg/channels/README.md | 51 ++++++------ pkg/channels/README.zh.md | 51 ++++++------ pkg/channels/base.go | 2 +- pkg/channels/dingtalk/dingtalk.go | 10 +-- pkg/channels/discord/discord.go | 80 ++++++++++++------- pkg/channels/feishu/feishu_32.go | 8 +- pkg/channels/feishu/feishu_64.go | 28 +++---- pkg/channels/irc/irc.go | 10 +-- pkg/channels/line/line.go | 18 ++--- pkg/channels/maixcam/maixcam.go | 12 +-- pkg/channels/manager.go | 63 +++++++++------ pkg/channels/manager_test.go | 35 ++++---- pkg/channels/matrix/matrix.go | 37 +++++---- pkg/channels/media.go | 2 +- pkg/channels/onebot/onebot.go | 36 ++++----- pkg/channels/pico/client.go | 8 +- pkg/channels/pico/client_test.go | 8 +- pkg/channels/pico/pico.go | 6 +- pkg/channels/qq/qq.go | 49 +++++++----- pkg/channels/qq/qq_test.go | 16 ++-- pkg/channels/slack/slack.go | 26 +++--- pkg/channels/telegram/telegram.go | 70 +++++++++------- pkg/channels/telegram/telegram_test.go | 26 +++--- pkg/channels/wecom/wecom.go | 28 +++---- pkg/channels/wecom/wecom_test.go | 12 +-- pkg/channels/weixin/media.go | 16 ++-- pkg/channels/weixin/weixin.go | 16 ++-- pkg/channels/whatsapp/whatsapp.go | 14 ++-- .../whatsapp_native/whatsapp_native.go | 16 ++-- 30 files changed, 424 insertions(+), 352 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 58149f92c..9513d8aca 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -25,23 +25,25 @@ import ( type fakeChannel struct{ id string } -func (f *fakeChannel) Name() string { return "fake" } -func (f *fakeChannel) Start(ctx context.Context) error { return nil } -func (f *fakeChannel) Stop(ctx context.Context) error { return nil } -func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } -func (f *fakeChannel) IsRunning() bool { return true } -func (f *fakeChannel) IsAllowed(string) bool { return true } -func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } -func (f *fakeChannel) ReasoningChannelID() string { return f.id } +func (f *fakeChannel) Name() string { return "fake" } +func (f *fakeChannel) Start(ctx context.Context) error { return nil } +func (f *fakeChannel) Stop(ctx context.Context) error { return nil } +func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, nil +} +func (f *fakeChannel) IsRunning() bool { return true } +func (f *fakeChannel) IsAllowed(string) bool { return true } +func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } +func (f *fakeChannel) ReasoningChannelID() string { return f.id } type fakeMediaChannel struct { fakeChannel sentMedia []bus.OutboundMediaMessage } -func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { f.sentMedia = append(f.sentMedia, msg) - return nil + return nil, nil } func newStartedTestChannelManager( diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 7f238ece5..c4d12ef59 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { **3e. Send method error returns** ```go -// Old code: returns plain error +// Old code: returned only error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } -// New code: must return sentinel errors for Manager to determine retry strategy -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// New code: return delivered message IDs plus sentinel errors +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager will not retry + return nil, channels.ErrNotRunning // ← Manager will not retry } // ... if err != nil { // Use ClassifySendError to wrap error based on HTTP status code - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // Or manually wrap: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return []string{deliveredID}, nil // or return nil, nil if IDs are unavailable } ``` @@ -502,25 +502,25 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. Check running state if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. Send message to Matrix - err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. Must use error classification wrapping // If you have an HTTP status code: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // If it's a network error: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // If manual classification is needed: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return []string{eventID}, nil } // ========== Incoming Message Handling ========== @@ -580,9 +580,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st // ========== Internal Methods ========== -func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) (string, error) { // Actual Matrix SDK call - return nil + return "event-id", nil } ``` @@ -594,16 +594,17 @@ Depending on platform capabilities, your channel can optionally implement the fo ```go // If the platform supports sending images/files/audio/video -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -620,8 +621,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess default: // Upload file to Matrix } + // Append platform IDs here when the API returns them. + // messageIDs = append(messageIDs, uploadedMessageID) } - return nil + return messageIDs, nil } ``` @@ -1270,7 +1273,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -1279,7 +1282,7 @@ type Channel interface { // ===== Optional ===== type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } type TypingCapable interface { diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 8bc8c8dbc..3edc5cb6b 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { **3e. Send 方法的错误返回** ```go -// 旧代码:返回普通 error +// 旧代码:只返回 error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } -// 新代码:必须返回哨兵错误,供 Manager 判断重试策略 -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// 新代码:返回投递后的消息 ID,以及供 Manager 判断重试策略的哨兵错误 +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager 不会重试 + return nil, channels.ErrNotRunning // ← Manager 不会重试 } // ... if err != nil { // 使用 ClassifySendError 根据 HTTP 状态码包装错误 - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // 或手动包装: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return []string{deliveredID}, nil // 如果拿不到 ID,也可以返回 nil, nil } ``` @@ -502,25 +502,25 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. 检查运行状态 if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. 发送消息到 Matrix - err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. 必须使用错误分类包装 // 如果你有 HTTP 状态码: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // 如果是网络错误: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // 如果需要手动分类: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return []string{eventID}, nil } // ========== 消息接收处理 ========== @@ -580,9 +580,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st // ========== 内部方法 ========== -func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) (string, error) { // 实际的 Matrix SDK 调用 - return nil + return "event-id", nil } ``` @@ -594,16 +594,17 @@ func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string ```go // 如果平台支持发送图片/文件/音频/视频 -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -620,8 +621,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess default: // 上传文件到 Matrix } + // 如果 API 能返回平台消息 ID,就在这里追加。 + // messageIDs = append(messageIDs, uploadedMessageID) } - return nil + return messageIDs, nil } ``` @@ -1269,7 +1272,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -1278,7 +1281,7 @@ type Channel interface { // ===== 可选实现 ===== type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } type TypingCapable interface { diff --git a/pkg/channels/base.go b/pkg/channels/base.go index f6ea691e7..bd4ced849 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -48,7 +48,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 450dcce54..04ccec8a2 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -104,20 +104,20 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error { } // Send sends a message to DingTalk via the chatbot reply API -func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Get session webhook from storage sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + return nil, fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) } sessionWebhook, ok := sessionWebhookRaw.(string) if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) + return nil, fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } logger.DebugCF("dingtalk", "Sending message", map[string]any{ @@ -126,7 +126,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err }) // Use the session webhook to send the reply - return c.SendDirectReply(ctx, sessionWebhook, msg.Content) + return nil, c.SendDirectReply(ctx, sessionWebhook, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index cc0ef4ffe..b3070a822 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -128,37 +128,41 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { return nil } -func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } if len([]rune(msg.Content)) == 0 { - return nil + return nil, nil } - return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + if err != nil { + return nil, err + } + return []string{msgID}, nil } // SendMedia implements the channels.MediaSender interface. -func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Collect all files into a single ChannelMessageSendComplex call @@ -202,33 +206,41 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes } if len(files) == 0 { - return nil + return nil, nil } sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type mediaResult struct { + id string + err error + } + done := make(chan mediaResult, 1) go func() { - _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + sentMsg, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: caption, Files: files, }) - done <- err + if err != nil { + done <- mediaResult{err: err} + return + } + done <- mediaResult{id: sentMsg.ID} }() select { - case err := <-done: + case r := <-done: // Close all file readers for _, f := range files { if closer, ok := f.Reader.(*os.File); ok { closer.Close() } } - if err != nil { - return fmt.Errorf("discord send media: %w", channels.ErrTemporary) + if r.err != nil { + return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } - return nil + return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers for _, f := range files { @@ -236,7 +248,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes closer.Close() } } - return sendCtx.Err() + return nil, sendCtx.Err() } } @@ -264,18 +276,25 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type result struct { + id string + err error + } + done := make(chan result, 1) go func() { - var err error + var ( + msg *discordgo.Message + err error + ) // If we have an ID, we send the message as "Reply" if replyToID != "" { - _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: content, Reference: &discordgo.MessageReference{ MessageID: replyToID, @@ -284,20 +303,21 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, repl }) } else { // Otherwise, we send a normal message - _, err = c.session.ChannelMessageSend(channelID, content) + msg, err = c.session.ChannelMessageSend(channelID, content) } - done <- err + if err != nil { + done <- result{err: fmt.Errorf("discord send: %w", channels.ErrTemporary)} + return + } + done <- result{id: msg.ID} }() select { - case err := <-done: - if err != nil { - return fmt.Errorf("discord send: %w", channels.ErrTemporary) - } - return nil + case r := <-done: + return r.id, r.err case <-sendCtx.Done(): - return sendCtx.Err() + return "", sendCtx.Err() } } diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa224..f3fe2a6cb 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -36,8 +36,8 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errUnsupported +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, errUnsupported } // EditMessage is a stub method to satisfy MessageEditor @@ -56,6 +56,6 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st } // SendMedia is a stub method to satisfy MediaSender -func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { - return errUnsupported +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + return nil, errUnsupported } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index d0c351119..b0b231d09 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -131,26 +131,26 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { // Send sends a message using Interactive Card format for markdown rendering. // Falls back to plain text message if card sending fails (e.g., table limit exceeded). -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { // If card build fails, fall back to plain text - return c.sendText(ctx, msg.ChatID, msg.Content) + return nil, c.sendText(ctx, msg.ChatID, msg.Content) } // First attempt: try sending as interactive card err = c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil + return nil, nil } // Check if error is due to card table limit (error code 11310) @@ -167,14 +167,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error // Second attempt: fall back to plain text message textErr := c.sendText(ctx, msg.ChatID, msg.Content) if textErr == nil { - return nil + return nil, nil } // If text also fails, return the text error - return textErr + return nil, textErr } // For other errors, return the original card error - return err + return nil, err } // EditMessage implements channels.MessageEditor. @@ -310,27 +310,27 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st // SendMedia implements channels.MediaSender. // Uploads images/files via Feishu API then sends as messages. -func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { - return err + return nil, err } } - return nil + return nil, nil } // sendMediaPart resolves and sends a single media part. diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 3a4f213ca..e8a70923f 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -130,18 +130,18 @@ func (c *IRCChannel) Stop(ctx context.Context) error { } // Send sends a message to an IRC channel or user. -func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } target := msg.ChatID if target == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } if strings.TrimSpace(msg.Content) == "" { - return nil + return nil, nil } // Send each line separately (IRC is line-oriented) @@ -158,7 +158,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "target": target, "lines": len(lines), }) - return nil + return nil, nil } // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 867ab24ee..e29896389 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -496,9 +496,9 @@ func (c *LINEChannel) resolveChatID(source lineSource) string { // Send sends a message to LINE. It first tries the Reply API (free) // using a cached reply token, then falls back to the Push API. -func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Load and consume quote token for this chat @@ -516,28 +516,28 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_id": msg.ChatID, "quoted": quoteToken != "", }) - return nil + return nil, nil } logger.DebugC("line", "Reply API failed, falling back to Push API") } } // Fall back to Push API - return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) + return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) } // SendMedia implements the channels.MediaSender interface. // LINE requires media to be accessible via public URL; since we only have local files, // we fall back to sending a text message with the filename/caption. // For full support, an external file hosting service would be needed. -func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // LINE Messaging API requires publicly accessible URLs for media messages. @@ -549,11 +549,11 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag } if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { - return err + return nil, err } } - return nil + return nil, nil } // buildTextMessage creates a text message object, optionally with quoteToken. diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index ff9a3ed1a..bbbf2da56 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -240,15 +240,15 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { return nil } -func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -257,7 +257,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro if len(c.clients) == 0 { logger.WarnC("maixcam", "No MaixCam devices connected") - return fmt.Errorf("no connected MaixCam devices") + return nil, fmt.Errorf("no connected MaixCam devices") } response := map[string]any{ @@ -269,7 +269,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro data, err := json.Marshal(response) if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf("failed to marshal response: %w", err) } var sendErr error @@ -285,5 +285,5 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro _ = conn.SetWriteDeadline(time.Time{}) } - return sendErr + return nil, sendErr } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 4e8074189..5fbf35ebf 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -158,8 +158,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { } // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. -// Returns true if the message was already delivered (skip Send). -func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { +// Returns the delivered message IDs and true when delivery completed before a normal Send. +func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) { key := name + ":" + msg.ChatID // 1. Stop typing @@ -188,7 +188,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } - return true + return nil, true } // 4. Try editing placeholder @@ -196,14 +196,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { - return true // edited successfully, skip Send + return []string{entry.id}, true } // edit failed → fall through to normal Send } } } - return false + return nil, false } // preSendMedia handles typing stop, reaction undo, and placeholder cleanup @@ -699,23 +699,29 @@ func splitByLength(content string, maxLen int) []string { // - ErrNotRunning / ErrSendFailed: permanent, no retry // - ErrRateLimit: fixed delay retry // - ErrTemporary / unknown: exponential backoff retry -func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { +func (m *Manager) sendWithRetry( + ctx context.Context, + name string, + w *channelWorker, + msg bus.OutboundMessage, +) ([]string, bool) { // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { // ctx canceled, shutting down - return + return nil, false } // Pre-send: stop typing and try to edit placeholder - if m.preSend(ctx, name, msg, w.ch) { - return // placeholder was edited successfully, skip Send + if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled { + return msgIDs, true } var lastErr error + var msgIDs []string for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = w.ch.Send(ctx, msg) + msgIDs, lastErr = w.ch.Send(ctx, msg) if lastErr == nil { - return + return msgIDs, true } // Permanent failures — don't retry @@ -734,7 +740,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return + return nil, false } } @@ -743,7 +749,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork select { case <-time.After(backoff): case <-ctx.Done(): - return + return nil, false } } @@ -754,6 +760,8 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork "error": lastErr.Error(), "retries": maxRetries, }) + + return nil, false } func dispatchLoop[M any]( @@ -855,7 +863,7 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor if !ok { return } - _ = m.sendMediaWithRetry(ctx, name, w, msg) + _, _ = m.sendMediaWithRetry(ctx, name, w, msg) case <-ctx.Done(): return } @@ -863,14 +871,14 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor } // sendMediaWithRetry sends a media message through the channel with rate limiting and -// retry logic. It returns nil on success, or the last error after retries, -// including when the channel does not support MediaSender. +// retry logic. It returns the message IDs and nil on success, or nil and the last error +// after retries, including when the channel does not support MediaSender. func (m *Manager) sendMediaWithRetry( ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage, -) error { +) ([]string, error) { ms, ok := w.ch.(MediaSender) if !ok { err := fmt.Errorf("channel %q does not support media sending", name) @@ -878,22 +886,23 @@ func (m *Manager) sendMediaWithRetry( "channel": name, "error": err.Error(), }) - return err + return nil, err } // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { - return err + return nil, err } // Pre-send: stop typing and clean up any placeholder before sending media. m.preSendMedia(ctx, name, msg, w.ch) var lastErr error + var msgIDs []string for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = ms.SendMedia(ctx, msg) + msgIDs, lastErr = ms.SendMedia(ctx, msg) if lastErr == nil { - return nil + return msgIDs, nil } // Permanent failures — don't retry @@ -912,7 +921,7 @@ func (m *Manager) sendMediaWithRetry( case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } } @@ -921,7 +930,7 @@ func (m *Manager) sendMediaWithRetry( select { case <-time.After(backoff): case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } } @@ -932,7 +941,7 @@ func (m *Manager) sendMediaWithRetry( "error": lastErr.Error(), "retries": maxRetries, }) - return lastErr + return nil, lastErr } // runTTLJanitor periodically scans the typingStops and placeholders maps @@ -1166,7 +1175,8 @@ func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) e return fmt.Errorf("channel %s has no active worker", msg.Channel) } - return m.sendMediaWithRetry(ctx, msg.Channel, w, msg) + _, err := m.sendMediaWithRetry(ctx, msg.Channel, w, msg) + return err } func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { @@ -1196,5 +1206,6 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten // Fallback: direct send (should not happen) channel, _ := m.channels[channelName] - return channel.Send(ctx, msg) + _, err := channel.Send(ctx, msg) + return err } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index b4fd2ba3d..e76212905 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -25,9 +25,12 @@ type mockChannel struct { lastPlaceholderID string } -func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { m.sentMessages = append(m.sentMessages, msg) - return m.sendFn(ctx, msg) + if m.sendFn == nil { + return nil, nil + } + return nil, m.sendFn(ctx, msg) } func (m *mockChannel) Start(ctx context.Context) error { return nil } @@ -46,16 +49,16 @@ func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, conten type mockMediaChannel struct { mockChannel - sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) error + sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) sentMediaMessages []bus.OutboundMediaMessage } -func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { m.sentMediaMessages = append(m.sentMediaMessages, msg) if m.sendMediaFn != nil { return m.sendMediaFn(ctx, msg) } - return nil + return nil, nil } type mockDeletingMediaChannel struct { @@ -247,9 +250,9 @@ func TestSendMedia_Success(t *testing.T) { m := newTestManager() var callCount int ch := &mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { callCount++ - return nil + return nil, nil }, } w := &channelWorker{ @@ -275,8 +278,8 @@ func TestSendMedia_Success(t *testing.T) { func TestSendMedia_PropagatesFailure(t *testing.T) { m := newTestManager() ch := &mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { - return fmt.Errorf("bad upload: %w", ErrSendFailed) + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, fmt.Errorf("bad upload: %w", ErrSendFailed) }, } w := &channelWorker{ @@ -330,8 +333,8 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) { m := newTestManager() ch := &mockDeletingMediaChannel{ mockMediaChannel: mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { - return nil + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, nil }, }, } @@ -628,7 +631,7 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !edited { t.Fatal("expected preSend to return true (placeholder edited)") @@ -658,7 +661,7 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false when edit fails") @@ -734,7 +737,7 @@ func TestPreSend_NoRegisteredState(t *testing.T) { } msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false with no registered state") @@ -764,7 +767,7 @@ func TestPreSend_TypingAndPlaceholder(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called") @@ -1025,7 +1028,7 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { m.RecordPlaceholder("test", "chat1", "ph_id") msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called via wrapped type") diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index b0009e482..96db964cf 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -380,26 +380,26 @@ func markdownToHTML(md string) string { return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + return nil, nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) + resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { - return fmt.Errorf("matrix send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - return nil + return []string{resp.EventID.String()}, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { @@ -412,9 +412,9 @@ func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { } // SendMedia implements channels.MediaSender. -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } sendCtx := ctx if sendCtx == nil { @@ -423,17 +423,18 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } + var eventIDs []string for _, part := range msg.Parts { if err := sendCtx.Err(); err != nil { - return err + return nil, err } localPath, meta, err := store.ResolveWithMeta(part.Ref) @@ -498,7 +499,7 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "type": part.Type, "error": err.Error(), }) - return fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) } msgType := matrixOutboundMsgType(part.Type, filename, contentType) @@ -511,17 +512,21 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess uploadResp.ContentURI.CUString(), ) - if _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil { + sendResp, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content) + if err != nil { logger.ErrorCF("matrix", "Failed to send media message", map[string]any{ "room_id": roomID.String(), "type": msgType, "error": err.Error(), }) - return fmt.Errorf("matrix send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix send media: %w", channels.ErrTemporary) + } + if sendResp != nil { + eventIDs = append(eventIDs, sendResp.EventID.String()) } } - return nil + return eventIDs, nil } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/media.go b/pkg/channels/media.go index c645a6180..95905ae00 100644 --- a/pkg/channels/media.go +++ b/pkg/channels/media.go @@ -11,5 +11,5 @@ import ( // Manager discovers channels implementing this interface via type // assertion and routes OutboundMediaMessage to them. type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index e5f8b8fd1..a9b95c20f 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -391,15 +391,15 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { return nil } -func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -408,12 +408,12 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } action, params, err := c.buildSendRequest(msg) if err != nil { - return err + return nil, err } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -426,7 +426,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -439,21 +439,21 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // SendMedia implements the channels.MediaSender interface. -func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -462,12 +462,12 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Build media segments @@ -508,7 +508,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } if len(segments) == 0 { - return nil + return nil, nil } chatID := msg.ChatID @@ -524,7 +524,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { - return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -537,7 +537,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -550,10 +550,10 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess logger.ErrorCF("onebot", "Failed to send media message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send media: %w", channels.ErrTemporary) } - return nil + return nil, nil } func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index 4fdcbbf39..b4bfd09e5 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -273,22 +273,22 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { } // Send sends a message to the remote server. -func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } c.mu.Lock() pc := c.conn c.mu.Unlock() if pc == nil || pc.closed.Load() { - return channels.ErrSendFailed + return nil, channels.ErrSendFailed } outMsg := newMessage(TypeMessageSend, map[string]any{ "content": msg.Content, }) outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") - return pc.writeJSON(outMsg) + return nil, pc.writeJSON(outMsg) } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 7f2719e7d..7c5a62801 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -46,7 +46,7 @@ func TestSend_NotRunning(t *testing.T) { if err != nil { t.Fatal(err) } - err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) + _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) if !errors.Is(err, channels.ErrNotRunning) { t.Fatalf("expected ErrNotRunning, got %v", err) } @@ -124,7 +124,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { defer ch.Stop(ctx) // Send a message - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-1", Content: "hello", }) @@ -179,7 +179,7 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) { defer ch.Stop(ctx) // Send a message; the echo server replies with message.create - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-echo", Content: "ping", }) @@ -252,7 +252,7 @@ func TestSend_ClosedConnection(t *testing.T) { ch.conn.close() ch.mu.Unlock() - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-close", Content: "should fail", }) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 0e2bea67c..0a7bf15a4 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -234,16 +234,16 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Send implements Channel — sends a message to the appropriate WebSocket connection. -func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } outMsg := newMessage(TypeMessageCreate, map[string]any{ "content": msg.Content, }) - return c.broadcastToSession(msg.ChatID, outMsg) + return nil, c.broadcastToSession(msg.ChatID, outMsg) } // EditMessage implements channels.MessageEditor. diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index dfea85ba3..3a8cf9652 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -200,9 +200,9 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) @@ -236,11 +236,14 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { } // Route to group or C2C. - var err error + var ( + sentMsg *dto.Message + err error + ) if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } if err != nil { @@ -249,10 +252,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_kind": chatKind, "error": err.Error(), }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary) } - return nil + if sentMsg == nil { + return nil, nil + } + return []string{sentMsg.ID}, nil } // StartTyping implements channels.TypingCapable. @@ -319,13 +325,14 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err // QQ group/C2C media sending is a two-step flow: // 1. Upload media to /files using a remote URL or base64-encoded local bytes. // 2. Send a msg_type=7 message using the returned file_info. -func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) + var messageIDs []string for _, part := range msg.Parts { fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) if err != nil { @@ -335,22 +342,26 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) "error": err.Error(), }) if errors.Is(err, channels.ErrSendFailed) { - return err + return nil, err } - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) } - if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil { + sentMsg, err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo) + if err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ "type": part.Type, "chat_id": msg.ChatID, "error": err.Error(), }) - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + if sentMsg != nil && sentMsg.ID != "" { + messageIDs = append(messageIDs, sentMsg.ID) } } - return nil + return messageIDs, nil } type qqMediaUpload struct { @@ -517,7 +528,7 @@ func (c *QQChannel) sendUploadedMedia( chatKind, chatID string, part bus.MediaPart, fileInfo []byte, -) error { +) (*dto.Message, error) { msg := &dto.MessageToCreate{ Content: part.Caption, MsgType: dto.RichMediaMsg, @@ -532,11 +543,11 @@ func (c *QQChannel) sendUploadedMedia( } if chatKind == "group" { - _, err := c.api.PostGroupMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg) + return sentMsg, err } - _, err := c.api.PostC2CMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg) + return sentMsg, err } func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index 7ed736827..83a912cd7 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -209,7 +209,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { ch.lastMsgID.Store("group-1", "msg-1") ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -303,7 +303,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -337,7 +337,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -383,7 +383,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -417,7 +417,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -490,7 +490,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { ch.SetMediaStore(store) ch.chatType.Store("user-1", "direct") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -528,7 +528,7 @@ func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("group-1", "group") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -578,7 +578,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "file", diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index acd857a06..1e4a4fef5 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -108,14 +108,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error { return nil } -func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } opts := []slack.MsgOption{ @@ -130,9 +130,9 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error opts = append(opts, slack.MsgOptionTS(threadTS)) } - _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) + _, ts, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("slack send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { @@ -148,23 +148,23 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "thread_ts": threadTS, }) - return nil + return []string{ts}, nil } // SendMedia implements the channels.MediaSender interface. -func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, _ := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { @@ -198,11 +198,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa "filename": filename, "error": err.Error(), }) - return fmt.Errorf("slack send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary) } } - return nil + // UploadFileV2 does not expose the posted message timestamp in its + // response; returning nil avoids conflating file IDs with message IDs. + return nil, nil } // ReactToMessage implements channels.ReactionCapable. diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f76d625fb..831eb43cc 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -168,26 +168,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return nil } -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } if msg.Content == "" { - return nil + return nil, nil } // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID + var messageIDs []string queue := []string{msg.Content} for len(queue) > 0 { chunk := queue[0] @@ -206,16 +207,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } if smallerLen <= 0 { - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) replyToID = "" continue } @@ -244,21 +247,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) // Only the first chunk should be a reply; subsequent chunks are normal messages. replyToID = "" } - return nil + return messageIDs, nil } type sendChunkParams struct { @@ -275,7 +280,7 @@ type sendChunkParams struct { func (c *TelegramChannel) sendChunk( ctx context.Context, params sendChunkParams, -) error { +) (string, error) { tgMsg := tu.Message(tu.ID(params.chatID), params.content) tgMsg.MessageThreadID = params.threadID if params.useMarkdownV2 { @@ -292,17 +297,19 @@ func (c *TelegramChannel) sendChunk( } } - if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + pMsg, err := c.bot.SendMessage(ctx, tgMsg) + if err != nil { logParseFailed(err, params.useMarkdownV2) tgMsg.Text = params.mdFallback tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + pMsg, err = c.bot.SendMessage(ctx, tgMsg) + if err != nil { + return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } - return nil + return strconv.Itoa(pMsg.MessageID), nil } // maxTypingDuration limits how long the typing indicator can run. @@ -420,21 +427,22 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s } // SendMedia implements the channels.MediaSender interface. -func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -454,6 +462,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe continue } + var tgResult *telego.Message switch part.Type { case "image": params := &telego.SendPhotoParams{ @@ -462,11 +471,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Photo: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendPhoto(ctx, params) + tgResult, err = c.bot.SendPhoto(ctx, params) if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") { if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil { file.Close() - return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) + return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) } docParams := &telego.SendDocumentParams{ @@ -475,7 +484,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Document: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendDocument(ctx, docParams) + tgResult, err = c.bot.SendDocument(ctx, docParams) } case "audio": // Send OGG files with "voice" in the filename as Telegram voice @@ -488,7 +497,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Voice: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendVoice(ctx, vparams) + tgResult, err = c.bot.SendVoice(ctx, vparams) } else { params := &telego.SendAudioParams{ ChatID: tu.ID(chatID), @@ -496,7 +505,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Audio: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendAudio(ctx, params) + tgResult, err = c.bot.SendAudio(ctx, params) } case "video": params := &telego.SendVideoParams{ @@ -505,7 +514,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Video: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendVideo(ctx, params) + tgResult, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ ChatID: tu.ID(chatID), @@ -513,9 +522,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Document: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendDocument(ctx, params) + tgResult, err = c.bot.SendDocument(ctx, params) } + if tgResult != nil { + messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID)) + } file.Close() if err != nil { @@ -523,11 +535,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe "type": part.Type, "error": err.Error(), }) - return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary) } } - return nil + return messageIDs, nil } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 1be51abdc..4f7a2600b 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -176,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { ) require.NoError(t, err) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", @@ -214,7 +214,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1") require.NoError(t, err) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", @@ -239,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "", }) @@ -256,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello, world!", }) @@ -279,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) { longContent := strings.Repeat("a", 4000) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -302,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello **world**", }) @@ -320,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -342,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { longContent := strings.Repeat("x", 4001) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -372,7 +372,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { "HTML expansion must exceed Telegram limit for this test to be meaningful", ) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: markdownContent, }) @@ -407,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { // Ensure the test content matches the intended boundary conditions. assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test") - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "123456", Content: content, }) @@ -443,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) { ch := newTestChannel(t, caller) ch.SetRunning(false) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -461,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "not-a-number", Content: "Hello", }) @@ -518,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "-1001234567890/42", Content: "Hello from topic", }) diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go index 6096b7db3..9689d5171 100644 --- a/pkg/channels/wecom/wecom.go +++ b/pkg/channels/wecom/wecom.go @@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S }, nil } -func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + return nil, nil } if turn, ok := c.getTurn(msg.ChatID); ok { if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration { if err := c.sendStreamReply(turn, content); err == nil { c.consumeTurn(msg.ChatID, turn) - return nil + return nil, nil } } c.consumeTurn(msg.ChatID, turn) @@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if route, ok := c.routes.Get(msg.ChatID); ok { if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil { - return err + return nil, err } - return nil + return nil, nil } if err := c.sendActivePush(msg.ChatID, 0, content); err != nil { - return err + return nil, err } - return nil + return nil, nil } -func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID) @@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa if strings.TrimSpace(part.Ref) == "" { if caption := strings.TrimSpace(part.Caption); caption != "" { if err := c.sendActivePush(chatID, chatType, caption); err != nil { - return err + return nil, err } } continue @@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) if err != nil { - return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) + return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) } func() { @@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa } }() if err != nil { - return err + return nil, err } } - return nil + return nil, nil } func (c *WeComChannel) connectLoop() { diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go index c7a4adfc0..b3a87e246 100644 --- a/pkg/channels/wecom/wecom_test.go +++ b/pkg/channels/wecom/wecom_test.go @@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) { return wecomTestAck(nil), nil } - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: "hello", @@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) { } content := strings.Repeat("\u4e2d", 30000) - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: content, @@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) { } content := strings.Repeat("a", 30000) - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: content, @@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-1", Parts: []bus.MediaPart{{ @@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-1", Parts: []bus.MediaPart{{ @@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-2", Parts: []bus.MediaPart{{ diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go index 4da7f0db9..cf1b45612 100644 --- a/pkg/channels/weixin/media.go +++ b/pkg/channels/weixin/media.go @@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), } // SendMedia implements channels.MediaSender. -func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return basechannels.ErrNotRunning + return nil, basechannels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } contextToken := "" @@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess contextToken, _ = v.(string) } if contextToken == "" { - return fmt.Errorf( + return nil, fmt.Errorf( "weixin send media: missing context token for chat %s: %w", msg.ChatID, basechannels.ErrSendFailed, @@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "ref": part.Ref, "error": err.Error(), }) - return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) } func() { if cleanup != nil { @@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) } - return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) } } - return nil + return nil, nil } diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 65fabe399..0e9010131 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -358,16 +358,16 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess } // Send implements channels.Channel by sending a text message to the WeChat user. -func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } if msg.Content == "" { - return nil + return nil, nil } // We need a context_token to send a reply. It should be stored in the conversation metadata. @@ -386,7 +386,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{ "to_user_id": toUserID, }) - return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) + return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) } if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil { @@ -395,10 +395,10 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed) } - return fmt.Errorf("weixin send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary) } - return nil + return nil, nil } diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 70b3e02bf..98622fe37 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error { return nil } -func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before acquiring lock select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err defer c.mu.Unlock() if c.conn == nil { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ @@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err data, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) + return nil, fmt.Errorf("failed to marshal message: %w", err) } _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { _ = c.conn.SetWriteDeadline(time.Time{}) - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } _ = c.conn.SetWriteDeadline(time.Time{}) - return nil + return nil, nil } func (c *WhatsAppChannel) listen() { diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 188a7c8fa..d0a74a405 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } -func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag c.mu.Unlock() if client == nil || !client.IsConnected() { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } // Detect unpaired state: the client is connected (to WhatsApp servers) // but has not completed QR-login yet, so sending would fail. if client.Store.ID == nil { - return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) + return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) } waMsg := &waE2E.Message{ @@ -430,9 +430,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag } if _, err = client.SendMessage(ctx, to, waMsg); err != nil { - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // parseJID converts a chat ID (phone number or JID string) to types.JID. From c36b06a901c3a4aa1870c0f9b8ca7c908779f23e Mon Sep 17 00:00:00 2001 From: DimonB Date: Tue, 31 Mar 2026 06:46:06 +0300 Subject: [PATCH 48/72] Fix Telegram HTML links broken by italic regex matching inside href URLs (#2164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reItalic (_text_) ran after reLink converted [text](url) to , injecting tags into URLs containing underscores (e.g. Google Flights URL-safe base64 in the tfs param). Telegram silently dropped such malformed tags, causing only 1 of 3 links to appear in messages. Fix: extract markdown links into placeholders before any formatting runs, restore them as last — same pattern used for code blocks. --- .../telegram/parser_markdown_to_html.go | 34 +++++++++- .../telegram/parser_markdown_to_html_test.go | 66 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 pkg/channels/telegram/parser_markdown_to_html_test.go diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go index bdaa51807..95dc3e9d6 100644 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -16,14 +16,15 @@ func markdownToTelegramHTML(text string) string { inlineCodes := extractInlineCodes(text) text = inlineCodes.text + links := extractLinks(text) + text = links.text + text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") text = escapeHTML(text) - text = reLink.ReplaceAllString(text, `$1`) - text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") @@ -40,6 +41,12 @@ func markdownToTelegramHTML(text string) string { text = reListItem.ReplaceAllString(text, "• ") + for i, lnk := range links.links { + label := escapeHTML(lnk[0]) + url := lnk[1] + text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label)) + } + for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) @@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string { return text } +type linkMatch struct { + text string + links [][2]string // [label, url] +} + +func extractLinks(text string) linkMatch { + matches := reLink.FindAllStringSubmatch(text, -1) + + extracted := make([][2]string, 0, len(matches)) + for _, match := range matches { + extracted = append(extracted, [2]string{match[1], match[2]}) + } + + i := 0 + text = reLink.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00LK%d\x00", i) + i++ + return placeholder + }) + + return linkMatch{text: text, links: extracted} +} + type codeBlockMatch struct { text string codes []string diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go new file mode 100644 index 000000000..7754ee076 --- /dev/null +++ b/pkg/channels/telegram/parser_markdown_to_html_test.go @@ -0,0 +1,66 @@ +package telegram + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_markdownToTelegramHTML(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "plain text", + input: "hello world", + expected: "hello world", + }, + { + name: "bold", + input: "**bold text**", + expected: "bold text", + }, + { + name: "italic", + input: "_italic text_", + expected: "italic text", + }, + { + name: "link without underscores in URL", + input: "[click here](https://example.com/path)", + expected: `click here`, + }, + { + name: "link with underscores in URL is not corrupted by italic regex", + // Google Flights URLs use URL-safe base64 with underscores in the tfs param. + // Previously reItalic ran after reLink, matching _text_ inside href and injecting + // tags into the URL, which broke the link in Telegram. + input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)", + expected: `3 → 10 сентября — от $202`, + }, + { + name: "multiple links all survive", + input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)", + expected: `first and second`, + }, + { + name: "link label with HTML special chars is escaped", + input: "[a & b](https://example.com)", + expected: `a & b`, + }, + { + name: "HTML special chars in plain text are escaped", + input: "a & b < c > d", + expected: "a & b < c > d", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual := markdownToTelegramHTML(tc.input) + require.Equal(t, tc.expected, actual) + }) + } +} From d11f1bc0643991d9a1fa8d00b334aa4364cb2889 Mon Sep 17 00:00:00 2001 From: BeaconCat <111232138+BeaconCat@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:21:42 +0800 Subject: [PATCH 49/72] assets: update WeChat QR code image (#2207) Co-authored-by: BeaconCat Co-authored-by: Claude Opus 4.6 --- assets/wechat.png | Bin 328393 -> 373987 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/wechat.png b/assets/wechat.png index 9a6130ce0694324bf31ea47506819e2e5a916b7e..07a05dd91d0bbcd0b380df27591deb9e20d45149 100644 GIT binary patch literal 373987 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 z zt|mNQy1au=&e+)afKzWSuqK+pZ*T`^){=l={)M{uD!>gn`rEf}#E=u~ejZ-4x-{PWeA7hA@s zLMYSt)WH$hVDShPwlrzp6`ps3SNq_lC5HLpsdB}6gc*LAjWa$2&p_r6Bw91J0zM7R z1{a;H!LejxM>QJq76O4*A6DSvR`PuyP|`g0mGCct6GLnA=-D%?F(>u0w=e8vqBiez zJHYo8jE(zy(D{LfoFE<&;#v0Ep&EF89XnK zCvSgEUs6i^c}&X;Qn@eIzFOZYZ5-s#pHk3fW+=)Ls1jxHW*&6r<+$L-LOQxbY(F@k zF};hH=|>t+f2~WRBr2Yp+xuUWg)e#&NG$RvVP#$VMu@tMt%(w4^yW97?b=5Xnpw7A zh(xsvB5;C$QZC?$HnQM2F4OF>f7ptfxdJD=rUEH69dKJzuX>JU9w(>+o0bBdU;b@ zfKT+4h5FgEXEiihU`|!`7_UQLVqp>}Vi6K&BcEA&X8W_@(hAmtbh^yyC-wEac_X=+ zB?SF6N@>48l^tVriF~`L0thGgPv}oxp_adR4M9aA4B(zPb}X`edc-zb9CMWImO&uv za(w%VcC6aTYi->6^!bbBcMbOy+OaueV3asi_z$s*k^mNjV2J3hKh>cJhpjJEiR6xQ z^HKy@HuFu$0NwIq)CsngBh;4hU!K9}4hX4w_Na18>m+H&HkMj9Qu2D3#w@)%1c9>- z^eB0Yxh(?Af$C?v@*mpdwkuP-?9>rZUxh%Ks;Jq&qK|6_d5$xD(zYDC1PQM`c%xw1 zZ&L1;f*oZz1ZF6!*r;|qn0Hk=WNuz|Rb$9%CpC?NeCzk2WVaXj+P{w8W_3QAA9h3_ zS-dYXvE~`5vVSb$XB=1#B)qo!$;f=irSo<8fr#+#SjJ6rY1zU=cH?x}lhsSjOxE6J zJTlN;nqVP0-6R;_?3C1@=_h&QwA;l+(zegGF%Mo& zz2~(QN^6-tFgS7ZOPhVG;AB3#=op_%r-cI zOL>TiOrR&wipf_<#(J}=E|w3?JE{*e_6Gb^_8TNf*b~5V@JkYdu3+(+00wdl?h{0q znUQ+hD1wP-5^(p(2@F~ricoVY^iFxstZ(8nlQ>N3Af3VjPC#DhJYw9;{7n-TW@1EQ z#cBWO-@p{r3ZpDPopf5K-%Ae1WrR6q(3ck$(&b!Jo2&^#8e|7Ju#Ys~h?nnD^o;W;}?w4L}oB=$|vY%HK`pct$`oYM%1M zo<}V9$7L=po@Q^~rMKEvyCfDH?_PD?cJ7R>#<_-b%!<3U48BlEpU4n&vj~!}9JzI* zWt1T`m6~p@Wa!#ZN7fB9@y0EekYG{e^Bt$xpBSD=n7%x2x=gmR*3ne4JuFSoHd*F^ zk@Ld|QLeb}yLN~>NjN`dQ#5sVSM^92`L5$GK}&j^GOy0Jm?~a2o4)wTo|gCVonh=X z_ZCSODWosFZzOyW@N~l*C-yYU>rm_ZyPF?epYC?wHRBy8EuAC3Do%B>cryCmvrtEt0xFle@LpwZ>lJ^Trla$0+lt>S(j#N7`bUn~q!?O- zs6W3pvpRd~cF(*|J=eWE>=K%HRR#>7no3~9Z*K!qgx9X;O z`-r67=~9c^_=2O4!UpH(XH3jpaf?cWg~YvtiTvvKtrtR$80>QYQyl7B zu!oc1=1PNQopy=;fo$Hh(WF{F`=_}nAXDE6~)smCj5IAgHB#Pwu?q@T)=&Zk_RrzNw?h+R(tue`PPE z)_$IRnBOKtpXaq|cKUO`liQC~ubR3^y3>4oLVhnLo}()7qulkOUkUG?>m9At4Ee58 zefy;O7xjjN4-;;;#xOK9N`!4CxiQWezX^@%O81iynD~3Vc(!>`F|RIs_{M(DU#-h+ zMr^x#UcW1xpe#3|X5d^IvG1>XJGP=ieYPRt$%;|N!Fx}~9HzD2{yj@Bit2&b$zYwsg@6~2%PTR{YS?)bHBSx z*dc%8Ktn^rUUf{yP*Gi>7LPmm^dSo~@%4-BrrZ78|NiRzfCszdzrVu&(qicLf4(*@ zf^s|Z-(PvpiFwBV`TDa5+Ts6vEquX2xc=W?>cNqpO#k~wpwNBD^ZtMToA53D&(|+y zl*s@2`d|C7|Mw&Rm!jbR9_;@;*#93%7Kg^(AU@jmKHXh#Y4# z9r*Y0zJcHBHlg9K|KGD<#Iuis`~D|oin#H>!~Wm-bjN=s)Gc!$EZcH3^zb6gE-o%u z+wmKX5%nIW0b=0}`Q1LJYIEp>Hal){vQsZ%(`G0gYjpU(zn^ih1rTZTlvJguRmi!! z5m-C)LTw_- z@~GHQpX|LZ?C0sr%jrwOC+Uytko$@+zO1_+4&O+ljs+=m2ckd z9gh~i?+1U>`Pf9%?dPdG&qtR}h3YBu?@R0ab@q{&n0LW<(;1EGXmIAE4Y_Q*^DzC% z-11b|g0x21WkI@IOPQ6PzEkMm@O1P*kj7=aj*WI_`OHgk?9}9#~w+Hdz2?V zG}IFyw*N(DdhIA?EGp0^?)7SA;Eua*A%W>{O+HL=4{dZ68lmCvA|J}Pkidh@aKX(L zG8tu@_?;&_9*?}Bk8Z<^|Jes7qN^@gWP&$UJiV=i^{!V8DM!5pT-7#V7(qzo$u_`- zBR6^6onu^-+&OD|0M~Zv(Px|uZ88*L{%Pr8bQvbvtj^gi1L4%v5x37?S+m}7U;mhz z=^UxEbmwgaOGYiC ztH`9wp|Z)^dX9sIJG^CTp=}AyyhY#JoHxDxbGP$jpT-XRkGnC{^>#6N>^{2b9K0=3 zu)~Nv&R53a!J=uMDLU8_BrU8UYH&Zv(XMunAb!iuAn@X4KLP^FIEyVw*hGw7q_&<* z{un&@0x^b53A@|e`Jx|9a6TnjNnL;n2KA8`CqcV`kw#Nd7C3>b&7<4+<{p24Qq}WS z?$p+J@B3qeFD0jCcgL*B;Ht(&-#{n@#6~Yd>VdLWBUt%Lb=nTV%3gLT*EWZMqMoUy}C}rD6B}Z53SG4&{x?y`B)KEsJG? zXD@pHwczGGd3Jmk{r!gq2mdpjKI`9Ls<>9YZM}zdDi;2N$?LngXm>91&)uX88hUo@ zYK&=F`C&fSQISkm*e+f{7Zi#!-Vt6MfmC~yrECh0PjC^b;%5<>WdZ>ViGNy~hoyXW zEN!=4z2Q3Js|3#J?ILa`CJgBC25D=SnTVE#58m*?>~j~AikkoC zH-l9HjNGQ(L(bXMj$vPX1`HJ-9G%?THTk4LcZ4>@YYXh&y&Gf9NZ$}0Rk*Q^lkm=fG!^hq z>2u1$(jP1avX|YBF*XHQ4Z)jw*DtRI;IEK`ygVWNGbA28@*SYmgPX!AsO_NB&imA4fceNC=4Jfw=h73ze_OeP?3}ajYyIVGc@(jOj z2F>^o``7qeG2r_b?yo-(hM)q)9GJr$X>_yYoyXPGA3# z_;K=$i9AJvBqO1GX#;3w>jBm?tiA019f$#PO}=vYFkCn;nr~g&F=Ks4zSyoV zsp-|MH{q9qcy7_Xw6MIKe3|}jL_Vr#B^rN9_7=+45MZY4k3L`jo1!^fUDX5`Kzze74Yz@cbFbV zg*`%f!#WvI(S*QLGO;CgwnmJe;_%^S0eFUFHq(8E?t!J&>ctV@- z=nvUfo_V&0*{(VZiSokToSfCVU2(+frDmru8i)Vd3{Qgkt~C>DzAT-g2U|Na%k4p# z)N@CbGpWj`h~Nw>M1pt@7#bUEygI~AO?bfCY^9Srn5n|S&4&l$6JT!ba#IaS?_H<| z7_FxYC$U?ye3fM^SOLqL$64K7j}}1hPw%hT`!HK2t<(7(=3`KkR%DV?Z9&cIrb(Q) z#$HjZ1WkI=1fLIV*f2kvcwnd9{54YAUd8t?7GfE4Z>)~vSNr!fK598RSdy~*)dj{t zatq04N8OJXrt#E%hl#Q7cggAEp(+sYGEW2ICb-L9ltrmOGEk%UO4(H4lm?Sn5P08O}_b@hw-|XvIXucJ~ZVxfdET8EWMkv5v0H*YW%5N#+Hcl!#wQPgC=60Bl zJndS0i=I|XjFp&LW(`cRIaOo=K-3s~OZ#R)o{CMV`V!LOR@nLdbV&PjRGV$MdXr1Ft^JaZ z_5(|Q>_i$v$w76&^pF2l6cU|6`yFShtxSfR$8%(z6W=Yt2fhcl@fPusXm6Jk z8tP&$m)b)&?UP2_a7`&p=8$2}V1MX^?l`=={}fIh%NcJ)t4OCt`lr`Qx2HIz#5L-( zAku1;KW`5%NUnhi_=kn;Zp%7P8?~HsyWXqh847VI@)w-^X)4WNA@L3-C;7vVb1ftw zwFRh1C>_esi|7o=NWufV(0ckTKYcNVu5d5RyBje#t3?&5(qAXrlqs2C;sAg&xDS;= zt_Z`Zc#SJ(#8?xg&j1qt zZU;*Vw$s)^NlkI;OXQiH+xdo1C->jX=WBHAVw>aOmt>7T?xG!(@}4C1V7<_dMR3_E zIt-UqSPO*Tg^c}Yisky-MJ%mQnrEXl-Isq@O!x9G*Ykq#k2q7o$5m|(Gw!%YZLHr} z5$1R=JIN_mo3tM{H2D}P$WV*5eWV5U6i>jb@NQ}`qk9gnw0<^KzMP&&^*no-4#DZZ zZ+w>~oMaJ$pzMv?FO-|18MDR|`{)J_R>Whqv&v0P>O z#S7l(YjGJ8u8sG}<9g%Q6BX3sCS;qXW7|$lrWh|Omn_Y`d-hYXM4REnIh*L_{*b)LuTIG#t`J3`u#X2n^F^syrKIMOn~#(1uzJ}Zov0VP(xfh8T- zRBw4%OImvqHVl=eyO`b9ee?F1_HWoY76BSPh0Gk1TUWzJYz!)OJ{IP5%n+o6FF<`E zw9>b|RF&sWb?DZZ4V@aq9n9T5{i~g(S_>D!PEonF$u~v#A*^k=F@X|*4)z&lcp2JO zjxx-IRSHV(V+5#L8xtfAPIa1v)`!Rh( z+eX;LDlvv*wEA+N(nGN=y&#yUSxnPZ%HN2vYudeH%40p(T0dGQ6MAJBinVnD3RWSC+3W_C$m@s;v)SQBz zXu!UIN9GZSRg;kz4W4Pu${{5FWquIOOj9JE6;{_CXvu$CQB9~idz!A_%S*koCWqa)C@;F!=qG0Tq^xL1@BE@C$ zLP(v~aCr8bDt5lN*3Ujpy){?}*hGju@7p(GPxjT zinVxjk5a8GoTkCB-re3?cWroYR*2Xim6TnO)B$IVfgd-|$j3+tpi62F`+>d0Uj?0xm*~J~yhinU%7NkYs!@`2U2(OJ=0}dbV3{Chk z^x}juyUR0P>t;$1p$UqM(I2tEKYLFyb^8-$$vam)4SA+pNw{yvj&CU8pzox>TulR{ zHZKYF=VYIwPPS*s28NcuI}P7fmUY_foSjHy6{a9R(S$xxjYal`#e^yBg6Pefq}z&% z?(GSim5=H9Z&xUo@qMv}Vcws27S#N8{gWM^JeWrj z;nT*L@d{C1^nl||FZv@UR#)$oESuKadeX?!mYazf_0rB|c1s5iSqwdn_7CRQH1&dc z>U#`Lk~|%ZKJbB7l>as8YNf~Xjx#x`YZ~E4$rOWgK5w#Ko{WF=e)fYL601e)p4kkf zG1}|}@I(FuJIDps%rt;&F;U10ZOnfBRVm;2^B2?}$alG8BOjS@K}6D%e1~xB(C_j^ zeQ49pseg{?k6`VWFv|=FoQZ06Bo1rTCrdk*2KCiiv?DR&l zVO=2OJs3GZNt@aZlDPLT?n6CF z#FCY*QV!Ni78(Zhh_Ex_+4}WG+5DRMz(^I<2KOg#oZcz+?s?=7rVc2YTgvJ#WfZri z_BVv{V6~V{&4&ocZj=1$9loPY_tE66=GZ^IUchCW1m9-lW5iJ3GmN`*{iAt#4_+;6 zb$(Tj60ufg_vU@7#tdjM3sIUU0RaI|p70l|r;kN;aXpx1)U0YN6G|UU6Q{uwAoo_H z$Xh)i?Fo-cP$(6U2M7ejmTZZs2yAf+>!$4Aq;b#=5~c={1krK&I7BaXxRQhO{nVde z0`eS#GwU}gD;xQOc{u80a`ec^1)s=N_R7#0osf(JAzusU%l;30L;K~imOE-K`ZL;R za<5nk;~G7xRClxAX!my)AD9~(bvb75aX^RtoUE*D zyB97t=jBid$^M~VOVmp$Tnv3egOT&zF5&=UTU=Z3`zzJpfj9xAA7k41gbu1{vE6Tu zkKWKxjX?u|8fqC%QU>(*d`#)eu|~5BE0AEc?9Mk=*FmAvLs=(w#5yf`y4qJy&=M~c znZl5Yp^{Z53cSOQ)vkgGNgA1LN`r6Bpo4i*BJ0!hHTP|c3L@CF7vC}wDFFMPLk|x& z@c&YQL;+SN>5rfO_-{VCyu1%VLI@9Gj}geI5jWFK32O9;e7FyQ-m zdn=8%m~LczDPJnM=3MPo#6REmh2w@OM3TDn@_}PdMYFuGXxx`rfq-92ZRdda^WQu?RB+c=Q`$3Rm!9N5T zJkZzYKG5A6+&30@1~NkKqyt+sf_-#u8x$n|L_?MHrT|)di8=+_pYrmk>d*^@4ixVsZ1e#@W|sLNFmaRP&X1A{dGaO! z9SZ?`U?HTt*LJOvtJdcZmCG8m)?1q)nA7DP9*l%Dk`KES?cO60#6dw_Ki1uG*5EMS z2BrvjSvZCG0)ui#SOy9+9isJ3b2Ldr<+e z1_B~y9L<{T;?xpci=A_oT;A&Bs3G_*`wPF)?y^o>aNjVB#&<{NE#0v{Z92ND-M!*q z#aPHjuPd59c*bZqJ)iiD!Fvbt2VcJJ_B1`h#+d5OQFv|d(=9KTL$(UBroUhkuiO2l zBK!o{8OyGM)VR7z;{hnSe-UY2@D0>9`#0kua+vQR#>LtKK|@zT)hlwso7r+z>vN;J zg;rhqS9Nvr_g~gyp!Q>)A1zuJD9JQdJAkc!Qw)gk; zN|c1s#~{hc4^nAzMhT=p5SCUjV;0Yfq$>4tmeh-oCcqilGdLpzen`;TgaPLT#YH_| zG`XVUaj{2WvmDK`_qNM%$uo@b&i0E>&NX%ZVsG@=AzEn*+8@Ii?4LU<8Bfb5ZxEz@ zFu{;NHi%nOp?(S`W8$3OJ){ZtkZA!AiE;AwJ%}7an2+_T&?yyA`Ky^_!PHoWunj)B zCx{z%<$SEH9Lj1BI5k9w5b_N3qby(+dj`v6``KHO;ep(GTZuwRu|t) z#yB1*6JLI@#PuoEEoakZX`VHk5}b|G9#<4_Ic0UwDAqM9Bk+wq?zifO=kAr_>ttN}J*~$OzNdB770hlDb9e?EKmfHwuM4Hz4!YSU(bXx9C1m z#+1z$AWB;CEOj!kD4^EKV;IZ4nfRl~Lpo$48moSM+*QX@{0> zxA?@|>@udQ$~_GBG%*;{dFX!J4{G?947*0W^g>&5qhn&H(Nt`_KS-UC$GydhufRLa z<3LMU0bd+^x|!(=?&yz5OW);Mv&*-qT^c=W;`y>g-a!T?47Oj0T?(MJd_d8LqL`>yZ zsfwRI$S6|`TRj+Nnr0h?hM}cFS9J6S%es9JVLv|6f)9=Ylo+Vq-*wN;Q*yIKuJ@UO zfw$evQM5@|i^j=;pbfr*gg9)R*8J_=-&w_ZV#4DEi&g*xvwqjxO}PC~_KCu|2kr)= ztkwOSn^r)qfOPl;{84k8>Ghfdsw+!b$Zv69hE6(k!m773V*GkNOrpXgl__T6PNore zo9(^${XcX(bwG&?duD*&jE_=5KE9+07>oAc_Pnjr=FE}X%B}UL5T@(2)$mxMU<|*F;+aGk2TukZNjbgw zaxwx@nqQ~5Id%-8smS#_Ro8h(tNew4omqUI0X$|y3M1zWA?GQ*1iszioPO=sDuT>L zq^J#Q4!nnBR|dyZ{*8d-+>O#D{4gITR$qWo3s8v2*BZOW3aB%wr3p3w>xHDoGZ0dr z9%~y`?-l=_^e7_No+b+N6$q-}=q3IW8)oh1`XjVX+DM2T8(7%aOOGs(QQpHO#+U$b zh#|(MGfH*VwD@lz0-5rBH|BAHx5zaF#X8o0!fpBhxLp`gG9Y7Z9)f!dehgev$mcSA zSmT(EMq=7{sm2@v27a*SZ;${RM$Su#68;$^$+cM6FT%hU>NT1|2e_}|;xMMOzt>?> zp`^CpFnkP)xz!IVEJR6(>!KFx;34QfyWTR!IY7qp7I1m|xS_hb10X%H#r_}xG$5_9H~f}zxkx_Gk9rT$T@V`O1<3uh51@5Ld*vCh#f2@p>F+@f$zkdxBnvzkpbz$! zGb`PvK^%y5^%u?8|3PpafgM-VPds-pRW zacPy}-=Dd+M%zAS2Y++J;;S;#U3o!Ytz<4nDZm~8<9%)aw8XlX7x4_lRFMW~X&K$! z`NegAX3mZehguk4$4h`xem`R8uJLXwQNxlHEhRUx8U0f8I5h^|Q~C--yxgDs5m#2; z!zUGs`&(OUwi;g?1zv)+I5Rj7-27_ak^$lT<<}#E)`o9juH?^Df$5bI=YlCEXCNAV zWMB}ypb$*1wIW>#NNFZ1geLo;Y2T2Bj zf6*AIhhguK12zFAk|u2dISA@#ipfX2fr%ps!am%-9#;TeXKxRMkJWM{pD+Y5hTpJ4 zrVD=jEI0pMF`ySRK8Tpz-WN+4V3v=5_vY9dAtJr~-lhpqjEJ1OPIVFS38V^iZ`$hM zHh_VR{k)^}bONoUQ~+XrIr!@jf7rYSKuIg+oOn|Wp9P0sR$*R)lwVa{EnQbbiHKOM zl=p8~z+mZ;|I5;nFrXOSdtzO)y3uEx7rw!v9B}CIiD?F|&ZX#ND5h#{2IV>w07hJ= z;tI(mAr1<j%i}d$z!IsjLDcScr0Bzaz z3|s_vd$dl&|Ec0-oHFwPSzTb%MPf(bkEu@nP_>*VJnETnJ2YGHtJgLw3l z+uC-V`%!6KsEC}IPd6W=yJpFt&7`p%5RNy}Y9aZ*ff)cl=5Ixi!4P9E2KDFmAxCR! z%n|fhGEG!oEp4S+)I1(f>9~f+tAJM$uhVw_Fc%?0$^EHzMfHUAx zut-Z0XLmY!;$`>~Hgv)PUZJCD{d}_q+)7z*-BTJ;%$Fe_pT%vd;ca_hU>3qZDh{`^ zkXNBWrv>wihr~B>iurn~&taBrBGjOmJ^8)Kc7*Q&z-$MKxUW|H>H&V!k3~CCAt~>& zNAmIG$FGHr%jEj@>7jlojb*<-K?Jp|eDjEPI;X0;8zU--JYKr2QKM*!)rAgKx48?( z>>A2>NYh~iGWW~2b8)aw1TtyfgC=X!G~I;}kMVGRU<5@J+&fL*}C_5J&Io$5$49f`uqQKMH57g&!r>p?Upeqi%qRvGqw z858z4XVl5POoa9Q|3DUki{5WTpW7l>>fN{HHFHdEj@-?3IkfbJT8#wNzvBJ&p4Jph zLj;MkN(T@c<%ZwyHP{=fIG$V9-6&|Mz~SA96M#z@_#SEQFY3=&pbrODICynrEq9(| zdUd=%MzvO9=PGL&7%xhv6sk2V)%Y0A=Uo9XWNn;UU2z~u0<7#2b8M)qcIzQL2EeOm zq*A6oy}a(ni(^nUY|Tx&*PQx|=EP5I?S&49e@)SJCt}d_HmWKRKdj<=^7h>_Wr)o> zo!DfA(rX^z=rZHwzWIH-dAYVqt(kQcpCV~ky%Z}@U3$!~SWC(hkapI;0Mt;cM)^f2 zbTy%;CTQ|?l!t~9-+PJZn8xHlUSK8buq6y_AkynLFaV`Y0!%ZOK3mIHzJT#&_rQW2 zaW<9%Jhb0pkLY>g?Ql%gAWoX85+^7&F7KBI@x}eq@5j&HqFPOOk*v_`{TtPvrbJOy z+I|=+O7}vtX2!``^@kx~F1>i|t5h z4!K3@QHw;B-=))6THUh^rf{VD&-48{EJw3I5&!LrjjQQg*+*BP7)zpIPT~AMRG+{|r62kzRh068u+E4s|Aa%! zh0g6%E@IYE07f{^cbjkn?9qoP!#ZWL)V8jUq)(p7wG%%HOJX_8_a&NjQIq+hP@1}D z)D2Xurr%|zu&%C)*@P0llDpAEO?~qhvMV#>T41}q1t3TDD zf9PHMk7?DPvwLV2X#PE^CED?wuPG;Ujx~(rKrKyDuz%4)te9h@&1e5U@aNhm+TA+` z$M8@TYb`Tqy>xSRl_N5smbo6YyVb~hUgx|W(cfUjz@jP;-`?BdXEe#b?sKcN00&kGN8H5Jcf1 z0y;}Yed(!>N)}FxB_(mRQA?lQD!WyOrp1_XQy!J`v21n+YgVaPD|Bt9K`IF2Gwll@ z>#yJ%@D?dQGIF{12TN{*Xd<(Uy@LIA?05?6{+$O(RLL=8bq7vGKE`8cPO|SnUNkl9 z?_Da&uhV1qTwki5WjDfb@aEV?3S^-_@hWtFLc77j6HLF5{a55T?wZ{p2u7vQi04*T z6NEOA+&%GWLZqB;R&wdT(6COmoJf^PF$NJ7(HU0jv95!Er7PQXQ!z&tIRd$6Q&ZEwb3-Kniz4eo|@K=iUx4=Y*j&HAFE1XmZZo zOMSxFvk?u3ML2(z7h$^x!*j1hzsj^ghCtQB2y$vdn?Zp=+GwLGeejNe$RFkoE}Qd~ zx;Mhu2efsPwB;x|->i7Z86}b;ZTND^NTSUM{&23%;XmCF!*Gutunu}`+24yEL6y^BN9kjOx>47% zo8c1g3!2RjQIW9UI#1oGa3jYgQBU}@D(GmPpDy87Phnx9%vQ(vi~2=D>PyT1|B|@SCusWMEx#Z~xOi8xCj$5SLH%9nlk) z<@Pxk{dufyo3ahow+&I!G=-c9;_R_%UNYH=fmN^>|GkUNdM)jBbZeyycWi{QE{wy+ zZYL1!5tlOylYo0`%L}l?e%~Uem=*_LLPHUsD?pbbjhv0PW+O?NZqFe-Z>BGu0gqk! zSXuCX8VJ_=GH!|4qv8@+Z?1Hz2d!a5Oq2Y&Lj0xKxyFzUK|)TCH-q=sqaV9K*y<~{ z{vN=K{(d$Faqu=WG~7P*->o$~@OxDb{sLu^J%zy{ONv@ycgfAl3-8wB^lGVcl~^H> z5#SpGWv#wh)w@B~k~BFBV02guk{_1{@3VYm zR97n}+N+qEh5i2E<{^L)K0i0|^Ufw^7Qqs;XlU>0Kz)yxkkkLs2`iX%;04Q(o-lPq z9y!ie|NW<}=uC(5*ciL2Ly}G%AAc)1K0d$m{jOq(fV^G76GA@4!01_wcM?r`O)ecn zWvWd}Csn0{zrG=9<82ng>n~*85Dv-f&NJ5;xavAxR)Tp>RWWPTva_W%TnvYQuXEHK zV1GZGZd!>U{V5c##3a*KB?DbYZAY$M@?0RHWJ`v0Uzz<%dq;u(jvFA+kRT4aNgsYs zY@(E_#KPzHdReQ0UWay`U>;-wAg;N@@UivfI*&jx3#6LkRPi6yN(4qaR~v|}4i#7| zPA}Yk(#e)3Wi1;GZdKQ5XkjE5I+~SvZ)ZO8qxBn(HQ{5FKrJI#2u~ZO=upNM1Az<9 zfWMg-MOH(=&xlAa104-I9{Z0SGi{cw{!wB_I|{ri-#@{M4B@PJ2F=L2x^pm@ew)K> zG*xvCDjD383MfgpN%3b~s$Fb>Y&uq3&9!?eg5VIs*e^!=j4-LRcF<(c{}} z@F1fHbmwmNX{Am=gWi(cnu0s>N7q4gmO!OC}q-1&tZz}r-_QqL^k?h z^tgMJ3EnvnE^5s_jNSm^5WsnG&|pkeQu;4u)ba$?YwVa5n-2iM1uzhXKUvSu#d{M%NA><=i3CC{aqp8EP4YHq zL*ReqldN@3_UK%bDCEvmnZZsdoG$K$#5m-mfwNwPQDW#%vVuA0@ESO#iO(0o7=-`; zDR3>;%n2vfO^kbu0lt|f8a%s5xDiShpu@sCTwpc`5IV;U=C04DkJDWR|K@$UQV2l5 zj;ZXkg7ZP@85*7DR~l(dWW`yt@M|;UkreY(%P{v4vmk6r0VUDM2m4w(iU>|P z4&{ju)VLl3It4bgv7NmI2Z!gMRdis+vV#xEV+7|1;5LRi0$+^ai9&!GAT~RE;7c@S z`?=NtLW{siLa?U@WAf0QoRhS;vY@OI!8#5Qqsb*9#zKRQU6T=#%XaKG0-DNGl1uY|zvpzB~oZZfdBkvWUt%Vc$;i^szTME34F;=-^D6>q2+a#T zZNxIa6Y#`O@DqKr=MVxZA{g&DQvzj`{Vn7Qezb9N8z4oeeGCK1#;R!KVtv2SCST(b zdnQ1io&iR5NPSP?S|~>i8nwRK?DDOj`h!978Y-BJM~(qng!s=Yl+Ns*#uMHEMZq7C zesM|V=Cy$mH=wP1UT~6*oERj6*aI+dGcHBtOemoP^3Ig-W}>s{&!Pz2TyW1Y{uveC zMg3k)8{&b6Hq=OUd)T(M^K2BGpGsB1oosl1akO1frP}N}wIwb1>GGGj95_eAY>#a_UC)1}V z;}hnGEA6{t8|z*0lPa+99(9!gbKEpG_WZ3v`)`&b?!XtwJP*k?`1CGHsJV3~!GmIq zxkI?}<3(L~RoykG`m=R3Ue#HA!;;Ju?)p2wIaZc*?qFP{dL9Ce4W?3)n6Sob z6a`LHn{{eP2-nQVJ7TjDFSv-uCQaKKg^#Zs5t{IozQJR$Gsjav^_BZVbQ|wh6!Rc! z>7Q(S`snd5yKm*Mkv{smNzK5Kjeg)tp={PwP*;gIjoIUqJlmaCdZ#)ma^du`-^jOvTBC%UHx7=`m}CfF<{L%bNRBPEuLC81Xhf^>7lh-1JEYS;9(_+T5|? zDk%q}N^;DBRHo9$Tzcou&&Bf!iB?<%e>}X~EKI58r42u;MB$XfYyE&+<2r*Y**0Dk ziJ;MmMU@2@M7j!2ysQUG%V9gxgmN--l->1EJT`amUz@bK8bhZuSsFIdD;2282F&%>eP9UQ@d6g9 z`0O-TK74Q?;rL{H*Bh8ofK)#drg=#M9tYLRxkk^b-g@5~J{r%U!~0MG1pDa#vCqH$ z$VS-=t*h_8PZpT7w})>H6GXt;Xitt?b_ZTSYJW9EuvHt**LE8_F&!(=Ns-w@#M znh;E3Cz%ze$B%n}>d2`Nj^-x-s`M_6-{p61iK>LiJ3#-IVUMA%&jUT1AoiK7E*eV; zW#M{t`3=8m+1{I0Jvi;`EDCgUI?#IJ90?GPb}lV_>EwfK?wqHkd5s4Nr2) zu_Tbr@_Fzvescu(SO%k!%mA{m@58Uti(La}h#)}IC>;IYn6{#_vr4@YLVO}1WR7gY z3;!;!O=O#$l4xOI5I}n1CJch(d52jrO!MDaJZ}A)dx0RAbQp1(6og|P{FL|Dt$qE$ zWGj6gkUJT(DAlMt1RJ62k~c6ZfNg8ihq1%(3eCdqT%SAp$c?Xlhd&oPQX0Y1%ew*E zA^e-CRK8ypNJhz&>FTExCO&;#S?mLY`=`m<(;>m^4N}%!(muSpOSGXj=$dElA!QM@ zFBZFL(qVSiD(wqfUir+4ZjB>4k;!hay{HXiDv2st^_UqD#|(lvxE{KW1UNV!mXmbLgo zJv#swB1to+wjWLAwt96nBdrVr;Au1|(d)T}PqmmYbS3qp-$3n&cRhh}6$HkPdOnIx zYjr55yeF{rhR8i&bNcaq5CB38LEZxgK(%3*^=v!+AKVz(Jt_R;*~4alF9R^7{FBV; zUa=<9-{(<8^R1`VXoICX`5{-R%TVzwS{dHYjtHgWpuGEsDR4x%b)W+#W{yxXG0yj= zy*&WQ-JtrK1mje7sTbbrKtF-=yT`O@IpKG^isT95rh+Jcif=J0hs0VNp&Yv9)iA)=6i z?HaHZv=nc=*fj^$s^_+T)qGo?Tgs-&QM*%kI+4?&jk%K`9*$}D*1J0U$7Rw$SxW{V z-iqX%m@-8zA(_^JOU$SpTFRsi^c z4PZbM4FnA|Ds?X-=x6&S;b^(Xy?E*e9#TOUIUGyK4QzQuK2CLmeBqd#mpCUys;fk7 z>iBJ?2icXgQK90AjCXLckCiuLy*gZ*jG$d1O)j6pN83&EF`FjwBz-Ueeei!fhgsEZ zPTBsgKt_xiQ&wjWGuFX|bF=G=JBBkf|UdST8tuwE2J-%g%W ze)@IGAmYofEDI~KBw%{uMzGQ(&)qS8$K0WNi4Q-c_`uS_fL|GJtlf4QtK~ngI;2e4 z)zL|TOM(j3@0mCMW{UyyH@yqn$J#sI(Q;XXEo zV*XekR%KJu$R^#pS9KQzvD|$YQR@nGT-S&E=@36u!&TKfusQ*?--s+%UtjdI9iLM` z3n)qonKdX6S?E<6HDnXU8QIEyXw{_A*g`8!7p5tS@_HHj zLF{9K&~3i*=*(EYm;4IJOZ1UBhXReGLn}Xr%;GT|vCLzzO^dbg)fGIkd8XY2O`#zd zrl{xOjcVGBdP2_yGXm%U>?WaAmN6*Hdl}{{RzeydA$>d4`8kL7vlUTqi0dsZ=x~(W zd)WX%20Ol`OC|E%KO9A(4+NCBdMMcl-jdNUD_2TP>8B;Y=mtzcQ1A*vYG|2RxqfcN zVa#wiV$^yn>WqdK^&sNEkn{-x2Q0h*M<^I`kS17f#Xd7MHKmG^TS)%a_T?8;#e-8^ z!{UnI40`n{Zwy5WV2#-yi6`VQ_{#NQ+FLdBPoWyKY%#t;0sN3MYTwBJAcf-?&|1Pf z4;zw5y}f+{bnCPfw~A-Cj-RQ8?6DorL0%D>#8y;yc6~bw{^?$E1!4Q!)4;Z&aSvWI z<|tpK*I)P173WLs)wv1gExR(uy1!l?O>*QXKg!{N6!45A@m?x4Mf_`BNr9RfQ`5XP zcg=F0=@@S>V~C#~emj2VYApB~HxI@^;1SGMSBjSIG31iKj1FcbwDx*cP?+jr3V8?W zqwYGF{$CKU6hj88x^OFCG1q5RYF&%fNlSZ1|#8Yd$AVlG=bST z6*l^<)={lZ{~4y4w`Z?%002nc>f;J;*%h_5Pq~ za!ZI7N?3%1L<=-9l*CcEU3x>2r^#|sBshgh?ODz$7x(SiY9^ECb_cg2xGT5XAiwT9 z`X9hUMjyO!z}*hp;cI%N#=23Rsuan}$^6SdThWhJm_@IMj}e=><|5x}Gyod_E}i_;h|T2My{$2rD~SBiedkY@-`^+Ev=8qSBK{Sr3rhv7W}mKB`!f#8Y2dF9dHBA)Zq{K-zj5YB#`EdEQ?uo9X7Nk*BJsb+`u}ID#WU* zzYHOfJe4{5e&8DbHtO|fu+%EbotR8GZgUIk5z}e4hyK-b`|Wp^(Js3k0!{Ho{;2H( z;3v_3TZY#B;iAKN0auV1ZF1i4q6KdxA)Vu3zzq3PTe#4m<^fG|X@b66FuY;fHsoKl zHViiu6*cDm4{Soy27y;Iu5GW5*RbkG>qa;ub%BtFI?J6V@n)EM%zi^ijcm z=T;NY1YXBH>>MIB-HB{>dN=>^BkF2Q&2;kCoTjYv2`mLZ$Kr7RN6FB6*<^3-Z~uFR z-nsUHi++m_`Z2FG3xe+;@ZO?wYl66L_bdSY((hc{-eN|4$>Lwr?kR+C3hgkJ&X<&Y zeoDjFb?v-KC16X$i;Ey^kM5Rn`T=IgR*4uuISV}y6!gtgFs_&>Rg{FiJ9URw`wt66 zX!(GGlVQzt$=!--pIK!Vr>8@PmO=859h{KEjM=`tg@&7f^+swq1pdLJ;EN0yx??k7 zH4RNW0JW0A)nHBsJCM7%c>(sVe_gR#$>h8nz+CwDRX&0A^wO1HwG-yJuJP#sw z-WiAPxz&Qm^C}5=KCe)o|3$#bzNKr*n8w`TOhoaV0d5^vJ6K$giVQ0XIxxxr?3_1g zQfCnFX`RSJQ#kb$YH^QVnB$?1z^w}&mn}?|{FE`f9VF4kR=Xgw-HE zwD8lb1A$}&w-prGSpk`r;2BM55PS@@hBe%qM+N(aMN=a z^UD+8KM(AN$?5B5X3dw-xcaJ94(xc91-_J-8+P)SZP#waS{SG(fjpE+2oohVDP6%m zr8gHK4q#z;{?e8AqH`lpB|H!>=7F1(Oa;4*3PSnr?DkvKYs zll2K)`RVq#`w^AMZVzen$K-=P0&J9%3BjmQDT zEA^;+3Jx;^6)$6(rZJP+A*QEP(N{#wY=t?bKQV%Vcy8}EiofeUMlwp6K&LvLYPpDu zsUmXR+lQ@Fqse}(M#5*A%0F-gi=4@pu2UCdN3Ibkq~u#Ty2qcT{~AweY0pmCy}-wf z0a1gKQ|TgzX`Oo_{-)z7zX7|0?QKwvqN66$L|H@Ns{-GO1oO4c!yGjQtT4GVy_?`H zG$xTy`WGLVMwgfTR;ZrYfAi`9`tvE6z05EARvEycSt6fcVqzlqv(1d_$sI$gcWOod zau)I6p*T#}5OLb-tfoYC`#R3hQ@m^0sE?NDk%~$pdsQz5SQqq#=y%1i+NJ942_GUK zUA&tPC$J||C$D~%`9b{+>c_VFT`NCZ0+g)bfp_n}rq1~;I=9mEvXQc|r{nMNy2ZWW z*OMsy7O|@-Un=(qv#WFg>Yr0C>4McvkuY12EVcbpeYGQN7Tqy-BosJ=cEyGypNY}X zT^^T00Mwz&TO6j~iPS&e4A~gp&5}APlDoP6Xv|e`?v}KvIju=KHD?_*#=sM?Y8p>M z;fIeD;$td$jPePMvxYJ)NOjKC)0_2wk~YPcRX?PXLLUn$ziIN*3fduTtPKR-?yNIr zd;>PM#_ogSgQ+!!SZT*qw0Kgt2v>K<$&fX9r{#O>7h z8)ym?Nece;%ByUN#R#lB6hqKAHCIGClmLH0P2UaC3!0+t3d_4p-k#xX#82D)mD52V_8tY%O`{M>SII_{3SBQ|w!j6#^OvGV9oo@Tugw7{Dm2DKX$ zmI@?8m%(FtAV~wN7nC;Y9OT&V&@>$83+}(H!AhXFL!BIa_KdK4HgE(#UA8BS!zI+u z41-zim+PNTGgX)%2JC|vNPwo@P(WHs)D-s%MqBXPE8CRoAk4AWCQmzc=nspvc7W5{ zj&TJo;Sg_68-N}Ou!W4#zBvaLF%SlJSIpTSY$2}Ec8p)$gJLn7DUd{^3D_fU5bS1{ znZ?G;P3|_YA}*OISq&bWCL`;_DKLR=*6~78o{^%HjURGlxgg3JLC3nFw|odJ%LE-NS~u?rxl>QDyCLtq=#6iTVVh45D6Cp>$xnLhRnG`05)6Kll< zh1LVn;uqi2u`7%?H#Sd6vb!7w#z%qsnMlD8FRo7K`F7CLF83QAy{~!pgy8%UKX5}_ z>N^w^uiA@?*I^dL?eiJVVW6yn@3P=S`XgX3V8#r9?Q4+$a?b^aWphp+3dihVgV?s> z%+axHFbrMJ(yhFh0)ok(GC5@RsrDbE>;LzX2Yt!1Jt*AEAOZ(K`G={Dtx}hMeOQmNCo?LLNt6Ph?ojOYn*kf5Bb!S}u675_`vKoA*>!Fn5YcwmIwL@EDgdh<}=!2@VXzoWYefC{2i zKS*SgfGuY*5S-6WuC7oIpDkKr`~2yxp$y_YuYUImINwP?cMe~U8jQ{rO8HhnR?Vk~ zp1hqfb88(0eiF*LVVP00f{BJgaopd_p}nfg&>gCVDcGjW=*%zxeRetZlwc)Uys=A7p;~dk4_h2Ff$~Jmxc)tQRoCO;= zs;*?XNi_t;^6e*HVD^!*KODA^C{#p(K9S$%;Ia9|a<`B4?(ex&CDe#FPzMx{+NMPf zq5fST=O!b-lEAp6Iw4eeP}q7=$&imO6~gCO;?_MF1hJo+Q2C%;_8Pg!SL%nf4M_|O zVVn}LH^`yF+Af}5=j=IZ&?y!#P`R^X%K5@uWNl_f`Z^T98+@QaS*c$Et#v|G@g`i~ z^e(&L6BLFaD;_C3dj4Kye6gYesdan>m2#bRZZnB|rZybnXC^PT*|9X)a@16+MKW*I zG7EYF+hxm(JOO{!TZye!qMmpYDvN)R9(+0qvoG)SWhM3!rq-LQM;oYX50y<*VgxJc zrN)U*y{}lOhSF6_$ij=orx6)*JgSk~KI3-P`O?#Idr`177_npE}7-Jf-_$tGIID8&p0-L-!zq{1UqY&g6GAf#C$`4H$+1Q;9t@jXG+nuhFJV zl!P}}jNlr{vC8Lo zHl>l7|5t4M4=VIlR^n&SqvFtPe8@BPWK@$HK8W2JP&vPb^h27x$Qy0%;scW2lOv9E zLvP*TPchw7zXwZq7A~`lByjUUq^yTY9H>LYG7!Qh7=242NF|)ld~*Ef(74~)$VYZ-Zu<&VSgy@^wZ$r>W8ntC~<L#5lwYD{p%YjXe2SfP0C9X$$~D?~yRwn|Y1UkQ zb72G=c%uSJV{_w9zuWIY7)W*^+Z}8Q+c{E!4L7B8sYsc7RlU$@e_g+s9dkrg7WyYgaH573x!2t8R`)k;EpJm^kP zt>|K#pEwaKC*(YTs_5Cz-brk2tGth?7UHIiu0f2$Dp#4g04JYZr;lfz46Xg2Lk))? z)y2MAJ$O;xr#6@;b0`Q)d8u~Z@}N~eFicGqQa z)6+zgXAIW*7beNHQ84+JnMHT*){wmY$*Nl4(cS%pJC%6&HzP2qZh7@tP22J9@^6gJ z#Kq$8GTcQc_^VFsMLD*cFPS&l&rP_O`lbT0HRnU{ZyO0BvU!mWn^2r;7~P_Gn7;XT zR$pdHG((2Xi_sfx7y13zB0aro)k`g2c2*-6n&jKJD8Ca*hV3y$`VkV7mlAplcH*!FB7E=o~ur#VZ7VQ3Zv|0I&6s=cB!;6DHUI_{Rn>RPd}9rwj9B-YZxz^ zDV1tATEn>XYde`MdX0K}=l9X#ExUg?yq6>TuUDKHXP1|ivS*>8eEpcUX&v5s8N+;#v0n3GwTu63Xi~mP4 zrsCZ(iw+AXiG3JbP|0*eAM$)xGx2p-R{`x?4BdWqXa|tPnHe)?z;7J<0Cs+1bcfea zdZ^UHXqO7|;KWwR69c}SL48^b9JHLOXPl(R{$0gdRUq}IkkH8&cPoWLQc61?DwTK} z!1w_5QN%7PUCi1#d+;)0A)+r)a6wgJx5iQ?ab->q>j~?E=`y4R6X>_7A%w4EPR$R# z11$N$?;M%&&n3hSI$KD;C!>1uHkTh~=_RHNuiG$iNS!KyxxtPPgiCU48%mYLd$%Lv zaR*ZYUGf<%K|WXBgn)rJ&V_Dh@-j;*KhJXJY@B3XTl=r|P@O>yd2Y}IgUkSMFTpO? z@xs{$4IrA%tc=Sy$YgY?}o)rU%ssD9{iXcZ;mUR)N#6BeR$TW zu;)9$;`wL98C_mZQZ>U{)?)3&1(r!3^s!m1kz;9cbw4?W*Ax;Y-+Tm|L0smCgV+*w zV#+435Ny0WZ9GMnZ5f3kSsa>-8nbJXEU_}iyc+dcsdfzXdT-aqmc<9Q`?fCEO){N{AjnLvn zE8Z99U@T`)s+{u*9!|D>=WZYiQ;J2f-NS{Wk|=x($;1sj@Jw!0XIC0&gKP%Su*mma z&~62+N0_eLB>h(?O3cqqrdGV1z7PN&q%Y&r|iqKnHpur~m zX1*z?!QWBhIQ!OHe<;GnHGvA z+QHGx{0JJ}?c!f;aY3OjM9)+)C9YZD$odRNjFNPofU}W)qD;K<%pbOuZBW(TzM4cs zpc#r&tY8MK2}#u%vF`uUk@Ks5=6?jElmCHx2%Aa}`w3PDm0N}<{PBjaGZRqaM>bP> z?)mS~;ijVjKdQhVoV~@hns5BoN5JTyA$jfnLuOBxB010xEU2xP7f?9^vcungffQ$C zik^ArAP`~+C^yc5^ym%0+!co|i!njvTnG52uE7GirQ|IL<&*|aO`MGPw0|PZQBEEU zFjn_cs0rx;K|BA+hg2=DYD{BeD1(HrK9fM^^AU`s|Dfy2fro?0l^S;he&@#V;dT^% zzEM6HXuzQyGK+B>Bq)Rn+4Ih8P@w=8S&npFPEznYIj|$MVpuPIO9d1svwALLEeZ26 z7*+sDS@da*D@%2B0|aKM(x4h4s$bBaCDzsH96|KT0>A(nn*tk)oK*%YVgECjP$cfG zp3v=l9sZXi9&iRlzee{BL5_xYul{K3r8N^#!+kf-)vxeB^alJO0Fn3$XiXRNsvgja zslt0W4h`0PH3N8%@2u5Obf!!OGADTEcY*xy!#DWg6cm8h8w_5hkY0|6b%a@rM zID10g_MHW{eY499D;6`zRSFEF`=>BVQ=z&QCdHLSCG0_)SxQU?Es@dFpMY)%0(sN9WC3L3sBI2#)Q_ZCH&@E= zf56=aZ?s4?`qUx@Q%+Jq_-It}gAI^MFi|yqhdZdEx|KGBKDY7z@$}u{Shw&0_TGDw zki8Q^2qDSdJ0xyG2uT!`oxQX7CVOPh?7hieMM(DV^n8x*@6V2-=ZW`yzpv{&UlWpW zAvPlkzsj@n^p0%-{T)wb5XXS*i>L1CfqMf;20ck;=Bl7~E9hSJZ2#(DoB)LWfe%oT zrIoVB74VOk7>cm2wmN<>aEE*cO)py{Ae^`rpX;AN&Wnq&qj+**=s?4Z9@(f=7y!00 zyX8xXsD`wJUcxVcWvTSKp^}~GH~IeP54?A2s7RM9ItqDOG@!GZ`|V}?{hm+jV!im~NYK%eY$eyv88O|)xdEt!i z-?x}m-s0X%9=I`@-C&UbC>37F=Y$Jg*MWW-e)9D%PttD}_l`>UJ~*4(^?;NK=#XKV zMmiHRh0-l(^a0AKXQ{HVn_+l^GPm?V*6Ik>DT{p*c%$JxqQBHs$LW$%Fn*-<;{{1@ z8b4yHGGMlX=xsY2@!BF6q8+c1sfy zOVWfDpZSfK1$xQ>*I!VO@sXAjTAB|$8$W9?NA`9o-_D0lr&DrlPH;%BVCb0{=`?Q&ZPUcuWLeaj4L3ZqZXvFXq}}K7-~n z1^3q=+!{wb;`orZ+htoB_*)%c)LMf;-P9DenM}i1_kgX>wF_62Yj|Jjoe6p9h4*sn z{u=_~B48k`CESvb$3*4P)iWis)|BUseOQatgvRR&yskIsJ>s5dxfOSiEgM5Sdapmw z=SSRaG0`z)WJ}760?SfxkZWWIYI^p4X)zhZy0qlOIbQHUzSHo{fvW& zS2YKKz`>1zPBmtH_%jHFY9Vc*;Ou6z`*#!O4ug!`KZ~*{ICZ3+5751TC&!z$dsA)s z04mh{XVgbie2yOgHwE#q;P2owk$~E^-scC&p>X>Jklta&VcGN5WrXV2AigGda3KKr z1kR{J!on;-rwOR|m%vK^rO5oYW|AP&dwbKzmNxXIY5)9kGYfEckYv(}<=F_I*$JnL zcoAh~qs>{ArN(+fA~FQ*bnpDUo0cZ#vN;4bYf)wrNy6);Pcg#cxm?&Rv%k9k41gD; z9=@kDGYj75_HcP!e^_1!3awKlsh|HPo7Nl;@UdOoArm%i*V1RlPHyNuiDUA0W-T>#KzXP|FR8 z#7Qb_w6wGwcT%4xZ7SY4Bp?if$^;NsfOfxwo*!%+m}FuB)IyxAGK8W_J~!{bxGEyQ zIk1BS6d*8$sIlJ^$^#HCif|oa8{1C3>-XCQqJKVDjOogwljwKtbdvvH9cS=Ww7D=1 zWY$jJJmtx7i(r$&pyq(qNmkyaV^0u!REmG1G;zPh5T?0ESgmHt)Qu2{w0tEF_-^v6 z^&jyP9?q4KlVfAt`sJ&TwFU*v`T%G{Jo3L4P9rNGxa>%}W&4HipS|kG$ z3!LmP20kJh+}6{|_kUdjl&qU4P`ZuId`gPB zd2kgZA_y2q#IcZ5;BDA56N{q~TWozUL=$pv-i35!czAz$swL~dH1`z#g}m;FVz0#1 zFfCKl8=BM>J?{*Mnb8kV9n?w_o3GdIq|G~|NO+VdrA@w681su8fe-Pi>1mYvyK3`I z_5u{=+Y*00s2=p>JJdrasELkbRrzy$kWl(zWcC@ha? zzDCI9XyRGu!dyNF^7d~9jdBvhTjV`_55JSVNt=Dp#o<_ z$z-451DX(s=GxY`JEI0uka^BfA-+_#<5}4-_7^pbx@<_*hTgR9vM(Knr(S~b0Fk$A zf{NlhWFwS@lrDo#%>WE(*efHTWTXoPD*Y}`|D=eL&HX3=2#4=qIj~7a?x|~LOMgjU z<}qdZR?j{dQ}MRB3c&V7qgyEDH~G2=1~uW0HBD~y^iqTa_7G#s89lOdBIj*?5uvG7xc}KL)-BQ~sCzcY^T%BfuA5uMt9JDF@&X z>K#-fnv!`c8LyIFJX$}VOH1>OzVI|j4;i7%yd#S7Qv{&Ol1bb_WtXHgw+q!A(&rHl zRN`5XT@FV)2uo(1AC~{_IPRcsK7~km$gFr(Yb0J9VSc0}|ILFfihW=vsfRkn1@f_G%W48E zTC#7KF4$tE3LUxc#~wc}Ot`{~Cyg@fu#8nKswy_)X<&20_)JRrYJAL3&xL&8TngP*j5hQ z{f;w96T6m!J1k(OY$;eMs=rGUqSdi)Xi^eu@zb`Ijt$MwyZky5wH+iEXg;8kNU?&8o=o*D0|nYYPRi=9vFh@Xc< zX=fyK6*RhVBtZAJT!1AR{;2=m?As%_Kn_?-{{(fUcddYk0zOx(&i zI;(nfRoXr30n|TyQ!QiOL+q2_)uAKMfewvh^Yevcq(W#d16I{hv|V6GL~x}T(mQIJ zj^3F;sYi_UwV6&r|GG32xO(!ZC%nIZuc)ZV$auKj_hy)NXx(HV(YGcThMs(c-oVXq z&gP%BLJPAp_~9AIcx9M~a@kaAZhGINL^d8i+HNx7^WwLLM@AK*KqCa?kn34Jq}YS8 zi6ZFW&-?oBiRfzVci~;(?`0Qn?rK^z7VF!55fFE2YWh?dLf?l|?9OdV-Bi$weq1NvSNE4iW(4*VrwqBP*+w`b^Y5uwqhUSqBGniO}iwMs&@A_d&0jr^vQFQmFPPtG2C2T;iT9y zR^t)*?I;SpCn`&v5C4_ZcErHUIwvaGy65&^SoD^QI6Oa>45@FPATL}Mt{XTo815lI z(ot=*t^E0Qc}N)t_*og-MwSPq=7L(}XS8FyT2o9&B@ZL=&=Qw`3klK?x6l^Oa9}Nlg-B6I%ro7|A`MievadC~he{w@HxsL- zxLB3zo*c5YFb%x_pl|7JvO^>L1h*UnuwFn6+2#pLNDr1P_$?uCISpf!#ugdp5{Lp% z0cVX&dRq}=o16p_G*R6@uM-ezIRGl<3ayJl5H)xQ<{f@NEJ5-NhFm%v>I>m#CwTl;5;C1NDoL;HI@NaE5--1${^xGp%e?&LwEDhk)WbSERjm?E*kd$yE%GH zJi&qE-wK88y<8Y4v}=oyUZBC}+dy$l`z;hQ?^ilQXeB)X;v^B!-*TSPJ@gh{>bYckaEljKRZ-mg6UbEz+2KbjqDMmyA-2}s*kU0{<{2X-CV_)|7qASd(5%iP3MIJIbci)1WwBYD56m3*iRh2(y${_vi_v`n` z`5-JTp`oFbc8DK!rto}S0^8}-xW$<56Y`8F1((6LKrZu@bDly zQYnVB`4R#GwZ38vH&DVnRSpt?8#DQ-m-p4hsd(8nc%Z*BV>WS3Gz@h7@%1K#eK$Rb zH(kIY?B80fsi;YT2v^wA@#3r~bW~JKL*X+ZRkjt`J7$F+C6RiJ$9U1Z(_6yF<2lf85f!F*HG<)M$bkEc|shD{#Yq9g>#DGj_^^t4-fvDd7 z8F(C>oeG!vHr5}PHo;qK>b~rMmp-!_CruLK&tYbh?RdLYxs?e^?Ax$o%^zv2Yd$Kav}9Ks3V^uV3r{) zwgBvbrKeWsMZpWquMg_yX0XDQ%rS1?;o#ZP-aD1}AE5EVZti%IJKs^?yeJJyv}n=3 z@pap#Oupix^N{WfbMP^4cIUc7g1wEI%PW2P1>C*U(o9UmmB%8b4py|U7RRLVDj8w zFe9%$EDq&(|0b1>={94Xz?jp^&z6Uj1Gk;I#(wQ268MFz@lS1}cXeRP`gS_mCEoDfSX`IjbLtVCs$fj(JPnb{|)^E?t6xJ5+Md+b@J%<p-^+(*Je>ndaG*12`8Tsj*_3E~uk@u01e4INEW^ zlZpf*aKA`^)#K5Kppvi#*dJ{3y}*Z|eyjgFok1}wTz0!MsK`QYz49lkZ{KAQSiWS3N807~hX90r!pG@S?ERj^L&6!7PMfMW& zjjUgE=|ZV2bSLmjXL$%Q+#W+uK73`JOR2Z#chf(+>b!kd->z6Ufsp^JNmOI{Z!~oi zzsx%Q(^iJ?sxG6uoL7Yi#;QqH#*Qh-K#*XFRh^YYIr(b%5Y6kGR*HjJpOazM=!oL^7&I)}u*60S}hk>=dck$hvuMLEjt-B zk3lF-${-=F2zP8BoLaEXwT|1}TG!)@hT9Wr*8mcGg9JX(u2H~I;V6ULfg8X>s?eeO z&67dXJS~%Vm&Z4817HXTQ5zIcS(L#?b?@$79-|t^AfBp!08-RC=AmC7rd(fuEgt;F z2oW$L+jIG}=5<({g8Z;|tsE*tx>jb&yQ6OvTJ`qwP~cWAeYy28hR;;WqWv@AfKWE$ z@)JBi{8UN&=B;pT;Ek%7{w0e{sNw5mmYRhICNT)g;{}0mYZt6q5VjQA!);urC92i! zKYm0qg!M~@7!(E|W%YaOTgj>Y^`?aj9FR|qP(Dk(o%(9zZh@t8dU=Hqc?0TsZ^+M- zWO(4b+6~ADA^BscKXB9<7FnMFRL}s`UU7~SxDf%Nf7mQam_^P&rN{uc-MCu^*f-(0 zhi(|2sejKF{{}%#tKJ9YmN%zE&aEEqiT@WX8!|WJJoaWCCGq34&p8hij4fnZH=Kn1(JE1kG2a+ z*!-T}<*8^_7QhCJv>~WCSx77ibue8XUA>z+|IOc*FL+5?68WllXq-A>3=Nr@80{0& z=Vo1LA|e5%&pyP+=$ORv^2jX$Aymyybo4T2Nyx;}$nxR*ifVFc*iUk{mG5l)t2dEa zJh>{W&L8+yJUSkJc`-bhH+o(O#Ef0ZvEO+ubc9zpuk`|k52hT2*)?Yl{Ddqj9Dnb> z6)9yTxshhWg@2gxkmk2sOgy((F^E*}L=MP*2}X$2A4o2`gEqjLl*{ROd;BI(go#2{ z629@sO^kRLFYC70TKR6$>hkpNxH#yjn*bQL?ION2S@Ipgt$@xS?2rll`_1C{(c$sV zT^RLtMaAh!>Q zR9OWD*pp9{zrgL*Kopq0GDPpUJ_Jdr^m3#QPfDGh5kv@(K9wl5nh;+Ku@T;Ot{tLr zGFR3Hr9URU3sKz*@#GkTt&{T{_bw|2uhTx$&f8M61HcxEtuLhtj%0UBuhDJ!Qe$X# zWeN{J?{5W)tgj;@BZG>?IX&?&p{Zx%0>okx7*pVvC#np;;f(HuXp&UNN02C>xnb&Z zzjpi%8=Htifza{!c!4fttDHuVyO2bZ#Ph(zf`B<0`JL&B1YY)xa>Qhz{$apVuXI}G zfh`wXzk+ewg7I`Zx#tBq*g{@S@D6-vNtNFX`WAS7F?j8lcpPMUVF4X&!Zdr?l@$x6 zv8B?M1pVyCp2;&bY+rtedqpW{#i6rNDlQn|huV|t1>HQRj`jhr(vFxty<99@IV$=7 zjYH(;x|h1jr?BTb`0H+{b=UY4qS*%rj-@t3%qm1;bXWl0SRuNl&R(k*hjzu9$3%vM z-22;C@7aGU6wt!fl+uJK6pYKBBQHKRHvjXrp14~N%Rkb?1O+{}+tLdZA7=b++F1u!2B;emIgm3TX15d z8Q`)*5UvZD-0H1#;0(hU=U=Q2Pb_Dx-WDugi2jiIEB9Gym{ z1iVs~a%!DHXHLQ+4^4WFlqwB?F~G@hJ_G8b&S`S!+C?0n#@59pDrd*-EkrwJX$0by zMevk2s!LA`081~|$-JM;8=~-`ouoW2p+8&WuYFlZ4878>o-a=oqD%G)=}NW8Yrs&mTRFm|UI zLExVdb3VK0XWq~Q{ep1}3=Fk*8$&qwpW39qLY+pf(Z@zDCG=pQbYam7K*btOK*k;T^0@b&9b(ID2O!;;d{|`Nh7BjVr%myb4 zyhMXW?tk{BL-jmWQ{`J0hUirYUf&F3l|6p`?RVt-^C+b;JWOU{nYDpm-{HBCp+N(|3QIyX6J7WQc*27w(m@3~0eEmQ@ zn&(_{>SPI&VNEXd{EyM_oB#gcj4ElxjiboaPQ5Op%0-|{^%i;shq7jSc)oWk_rCOO zMOXfk>}?wDFL-%L_27q;q_01QJhcx+8x({VE;A;pekvvjlKzeMk=K)X`j+wtk#Nw1 zjW7G3IDj`8ae?}mBArB&V%%thosf=)QD!?1cRl%FGv|$M%iPNLeZ@ejA+cu3 z$sN~}!O6v&|4MpeP^a6Yqa@fG!(KvJC@lbvx&bRfh8vNkBFp|khG)bxX@9i2DPV|; zf^bj)3nU5H41QTX>nn}a=f~d%(L9P6mC_CDV!mQLv3)~f5#w-ADvd7*s7HCYc{AYn zvuBze7^%cVA5?cnzHRFs9sz*`!mI+;dg=RheRMkb8P5Fp7WOfHYoIH29*FB@B%%a& zsQp9NhN92zX}NUVWqze2Tf&D{zI=DOF|6!C=Lp5Ym+kHCLqlqNLKe3=CsRj%!i%$FUGzV2io&w8qCB6rWe{^fn*FCnnY+(~$0*4J4jM1+_wv{*s0 zpEeTAvTQUPLizV|aKXH{0<~wqiTR7z-}QyB(SB{_&9a+A>_NH8NC(3)iq`XU$>Bkd z#WCrUmj={XhdFf$^#p{4<*o#f@l&rMZkQ7>^lgP@#l@)9Oos4cvrJD#e-c7vp-7VB zryAJtNl8glUpF5O=3jz7FQ#ZD*6A7y7Vywf9!jNPJ`cmlM)=wYE6)pfg9=(vh-u6R zl=f>mw63UQ}ra=L*PoXAbP&Cor zjuv>=^J$jGK?5^NF44o(V*N!5;*>$a*L)-a+YY}@O$U@|@7t*AAMp?0|HB>_`on|> z*V;A?VU!o%Ww21*nJ9WFe_RUrU2$cO3Ug>C9B*nilq&M#vo_SBO|`(fWx&CF?xxvNLioI;>PgjH!#o zqPZ|FPi+AbauYZ2Hn9ZgC~wep;(#hFw*p{B-dL{a-u?akD9#t_=Rke8-1m)sM7)TC zC2rTk0FpvLbk*3gTt$qdj%2BnclG9rH>f$hKiw*miB^9xosOL9;8guL4dpfxD@J}1 zD~Qlh9MO2mFe|1~s&PR&@WVez6o5Gp2TA(27tP-{G$FU>!a+>$z8_h%t?XU<;{mxj zi*HZTct1P^TjR0k+cyVmdRR0-iGoD%-TCaIs@Pf-k0T8{nn3D|d6!MSDg0 zlj=829w92cR?HcPYLNT85%@wbw@#{!;ULo4aqm|M9Ac6W`Riw3>^^( zB2>A=S=_!+iYyl9Ul0U9wn5cCtFyZo4`=tD0@0*%o}psrqZm*`M2% zmxP-2T`moZ+@NCDH+${QDkImek)2}($7W8%rEp2+kI^oo) z{6suXzZ$&0ARO%eM@UF$=ZuYuhlh8u_wpSV;4w^Ba~o2RWSCF4q9uUVXkFglF*&Jo z5jW&1WlsJ!eHVHjJu&#n#mUkVun`)b)t*y=Ua$DC|wLc$4fb#lZ*Mn>hNbk6~ z1&*?p!#oNK3NDv(6a=W0In}$+YB2lYqV2&KG3jnuR@_Y`35M_B`_7x3Ff`v4BYTqG z?FgWz*K$WrP}hnWL^^3>`~rcYB;e58#ZU@$Z_W{wn8e${xNnXw#7pYwxNXqi^Ru&D zdzG|rVtm;5W;v}9c9;zA04z#PZMo>VTEFH6V9@51(7)vKynqOcCVBA#mn&$Z*5B*NO!>HKQzG2=q&{;-$~O+Bca58 zbFl6&_!{X=Vy(CAm|mZ84OcT21`ITX_g$#h7nUSj76fj46 zR!;7);$(K{fV&l|q@TUgV6XA_+uvYAOMfXc!od(AR}6-GFze03R(>bwMQ2|XB@yxI zEwva3Y~LzorOy^OsovSB1U7k=v(&1>5Iy&9(r0#NpCxeDeHC&E+Vf-^68pRo6gTI& z@|Copd5qwc@?B=v%JMH|QH7uxK}8-HBc^#v+DP^0cuYNt#uW9RgSM5*6Wp9^5Bzk= zHa}CppK_Pc^ivF1$SiWEv@y}D^f9$xl`hLqi}fA<#6WVHbN9v61z&;jULWp2hjqeT zEM{C1@?v6X|1NNPmAYxMkt_G>t?G6t^f(z)kFP7BJ3SKUoFWi%R+1 z0*fkyYd9Yy2ll-b&e{%%*!UOwHtCNxno_iI?PVZ=YCIZkAw8F~WYxcghWqPksLXe2 z3fV*Q*>fEx3(-=<0}{P7IiqbJ&E3y*P9WTI5eFefW#dsM1OSRt|58aE@t2jUiP9-p za}-sSrX(b^!f)0;=9l|s<`ylF`x1KxZWxr<6rSj`vlj^_nm?ZfzXLA~DypDERjQ}C z?Ux1<6FLOT+W8+YUY(!F*m!fFe`t-zTjUPkg6-_gth|M}o%e?Y*6IVtEKjU|%KI$8 zWJfsveq?CLY!u`Y+ANw37N)_s3+2{ocB{vC$FAdHCEpCazK3#kolW^nu#B&Vxl-?r zCTFh84ha#Y`SMm+dup%jxh1Z?z|{q0=$60q+Mw`jic8%dTW<&7!}!*hK}A+&yE)$* zXy*u7X&8epyHw)MFe|EfUc5R(+4;D?JF6=9&`&AVUI%kCie5Us(do@e$#nU6hlz9* zBBFa9_cY)$)2?|Xg)?hbe~`RO)d~i|`0tYswV)DUuNQ9x23wzw^SKBYvLD1i4=Fao z`~o3XvXVnvHAck_d65(v1>>p9n#2Ok7;~*-enZaIvrT#aZJHc?_*UVDscu|q{IlDx z!9}}e@J-;2?Lo?3gS3)7~|60a}n2nP}*fd>dYFLjZUX1E-nc@6?*q={c?$Sh*E^DQS~Q<9n9rN$@s8Ba)Si~ zh!`-JO@gaz#SHB}wV&lCrwbo(4BF+70e{EmKf4Dsp$`9GDV%t>zTP`d+BTkI9_OFVKzMVqBTk?RnNO(vJ$8p$AqfjbnA&Uv$F5;5|6E~ z!sKHS;rrGt31*t))8zsGZJH6KZy%?rOKQE*ySM+7`eFNKTIpZ@EB9A`tG*-JW2sHQ zlg8P&LjOAzOIjo`+5WtSR<}@(I7H!K`Y}1gd`rDsaPH#~jVN;ZAq3(^{YKB@Hw%Xh zXDVL<;Ouml`1hNhplfPMuYP`gaJmX;;m;DHbfEz2e6#`=x7W(CCtuS)3m3kf{HGMo z9pt*;EE>-{b_bBP;rn-3Y$5gqu8`hbRas+`jFiG>_$&3I6AS zKPKRBuif}se|>c>RGVq2Hcx2Z1ZIChYOg^x5m!6LHxH*zTg*yFwOb6Zvh!q<-~OWyX6x%BVOsJjS!4s!jRP7v+seS%~RutH{G>dk<|~ z7wVB`{}AWyA{wG$%edn3gWvmlrl3(WnF8Y#gf;5o%i?Suo&w1SM8>cf%qx>chLf{o z(Er}AQaroIcK$@-0Hi!GuhvYj&}}amW^OLv+u_}0Au>2;Fu=pJJ-CTd9^HcEifNIn z{Wd~Ai0+*dIiCA{0yNq_KjXy`Cbs91wB{@LKyUieF|5eqcVMSjfb2~+-2Px@3=*eT zaB~5La0*oVGvPXQ1A6E{vYRZPr*dOUrC(2ZaIs7{ygVkF5Z}Xb1ovd65$_=>t{jf$ zQ-qwU_v&p%^#{W<+6XFKM~E!BN#ALYsSsFsx|B7#b+N7c-E)O@(MAFLM=BJfgP-@N zMsI?JT{I}>IL>7S?o)C7u~B*DDM&Yc=#7#SV-Qz1d(o%Tcr#8izH2yM-~V{8VHqe7 zD8zv6Qv$ufMIo)wcYX7G@wzEhR4A}*`QJo!QwL$?c43xIfiDC6J@9Q)8$F+&F$GoR zlY8^RR%qNhFS0bmR)U45EJawKnZGHqmoQxRCl?^*E0M=X7$T-qr*vQvX3?;}HEvxn zqtbr+{#V75K!uE#-iL{A65pp>9p$)lA>WC!Om%)LP8SL#W0{{i!Bq|k<8&bkPL;j4 zlq)VEF+$t$b=3`8ql0UXYyf$aeL>6ZC8?QnR5Ajf`u9Tza4j5jhDE^VEAT3O-UmUe*)J_u*kd7DA;GSA zdg&Z;Y&`TXHfa$?lOUbOwKmT${C9cv_U|-is}V)m@tNQAm_ZPxQqI46O^g=-oYlse z82QgS?B2Io8fkI|^$W+mdl(d9%_;|mI|CoS{}gia!T)u5(~lUHQ{~=~(9dQafui47 z8)IUxo1)zrv`DMdm<5hXtiim6HF$Ac8u&l%S0noXdtjDaa_DH_@A%Q6it z&qQ!JU`dOyz`kT=E&DPP;;90eA2Ab0$idEr<*Uc7;3RNFF3naE4c2pn2o%9mq3-wZ z^P}j=nnUeJYRdCjsk|RclVCXzr7MQsVhiG^?~Z#+UY?uZjix0E%^v3RV{M~1U%ynpuE z?t4i|7*R~_1{zYLx@{$mHIpiqaF6) z9r#u#{B?Q!B1}NJH6@U*B244|9HTOV`AMUba;pvaW`}&cXXLUlh*;ml@y0euzML6MF7 zH*7>tUrdiOH|-nYU!+_@TnzGw!n&C!F0V&06bsajf$9YbDSE(GnE1o({0_22WKs%@ z9iwQX9G?t0tKEgZFNWD8lB-jNZCFzuUS(joQj#Y@mNC#t`^3*Q^$Sx#+8Q9oFK5iF z3vL*CdjC7N3V>wz%bI(6B~5RHR5+$Qa|stkqomHGoX@v{OaL3ysK9=U3M$44A>lkA zLk@mVG{u#ywpFQ$!1Z>s8+(L|wA+_a?3$17z~1vb*)>Mers{~hO53cDtq0CCL`>Iq zy!qicTg~NuI?xF=!k*A@dt^qw^t@M*i_wmej&iCQD)O*DVbHze(`Pz3xdy&qO{5rGBtw?)8s6 zIOz<7p6vb|n8_ewS2+I?k^;R{ zj0n_v5}YK9Uy_>jyZoTpd~#HaE&aUae>K9hv>8e4e>tXu7U}1S?;Ikgo3UPS!~Mi%k3nyZU$L= zqJO0wXRTyr5<}VgkIfZ^M|-3JlV+OKR*|;;sAthZTi?P?TIIKE_(M@&#{KASgoYb! z2!;;VqMqK}RsyH^hU2>CU@A3df?_=Z%47R)00V}Lz9#(`gfev&eM~~~@yVjv9}5M@ zYTB+?{5=`vYL_JIm!GN7>LdTv2cgA-M9#d+27%*=ix+HW5(Z1tRv%x`asNdQxf3!E(NT>(9pl04!RNOg1DaXse)c#aaSw9pJ8h^Z; zmGgxYDTt*e7rg6R;z)VXlG+>(5_gEG@QlM6u1bwc9r5i|i|33^-M&@nqiUg%P*;f) zlE(U!gRk=7{SFPD@HWKPLp3*223UFNr2}?bwo~aZ%BAJxel0juKTK1Dc~&7~Wn~4v zgzu?!a+=~(KB)VhXmNdd@4#P;a^AATC-ulKJSFh~)Z11}uTHU21f&2&F>+NuH@MSt+Oo z+>||C*qm0lHB501zH_@9UMPkmH3vjCs=^5^yEY5$$cAVL921L}dV;nvU72Y2< z1L+^g*EY@Zg1B910gN3M;WIt5^{%6>pm?h^u$l%I7UbykpEEX~xkB)*wY7Gp?35P& zQHiYb6-QM_6!boRfz1k&{a1}R`19Ik{%|N6K)$eR3xYxYcqevO(+-1cm6Hl}h zXlOqYa?0a{q8#fI+TDsXd5@KGnGq{AlmCjFuM+a>oh0TcLNsaP1~*UqWqqCDR}L1@ zwn+*Qxt=o{@sFoMDmL~YX75ka^}E3NNgJ(f1viaUN{Jdfg8wD5VYmcE$>i+f;?vtg z%9RGjKvckguaIGFU*aI^Wew-GUkuzVkLG@86IB*4Tk9pBjapow4-@Z2N60;gc9X$xKZ@%iE`?ls%eL6rR?8TJrKL8$Rfzk@8blk z3;Fhz$W;{8I|y6{4G|?0V%Jy&KZY=9@vpyK9lQ@`$2%UEv>}~$Cyfq6UpKT_V^qUU zYyIV$Rb-|~2_}j!q$0M1kOS&B;c`n>ovqYjp{s75zRNBBC9!|~XGhRYk`6Hf=NeFydJz5SOK%$?ZNRkxdVh4 zV9}-h>}r*9(Q(6nBpJe(uGVb|SgP;{cLsuRFbJ#jg8+I}QBnCK>cC6ok${PbsV@Yj z!l27BOc&;&!=-22`DTtqDbx|Jn5oANnXFQVVPO%mHbgF8BDiycMz`pwsc%u5#?+Ds zW#giX!sFCTX*Cow;SgU}&Jq+Chx8G6bt?_V+x0)^A|R^#&~6zQ#DpCdPPZpn^4NZ; zrl~dC1j{kAVc7uur4>DBK6AjVgcgicXLkTV(&O67odKnkyq zO|Oq3+Mk-1R`Xaq9JgY_ai>W)lD$9a=Ux7>Hl{(V>>E* zUWOA)|9LX{h-a_WOs0vw4f7EaK9;+a1|P(Pfg!I6+?=W5(j$U~9q{RVPNm_a(F(9& z?h1OkBKdD7?_0i#CV@3Sk4?xNF*JOzA_yPlAe$PPhU=4I**; zRSdK0#os6H65O=7Mb+cB@xoHz^=>~pIB=4=>oXMB`L$~We)EiJI7VcapXz*!!BpO8 zZbsURe8uff{uKE&h1=5VswH?dR1bsq4sE4Pal6=A1ywvzU%hO3_Z!-*UVEV7YHPeW z%J1n8`7DI>JVJGc2XrRULBVO`J->g)0a;&+A;HkU`+RtT6}W9 zohf|!Bgg&Q%#Sa_r-^X<6%)&l<^`DIJXCft%pb^&fFI%3C%Qkgkb8>%(jB$EW@?Is z(kaXTZVhT*a2WUO@9abeaS&zq_x^Dcqk?edw{W-scQhU8DjWS-Z*(ZKG%_5|ox~N&Q2DF9gS#%bxPB5#Pz}G5Y7U_GA z*wwP*KaiWPe{r8WxW!(&xFP_!9a1d6LTJ`PMfD%pFsz)l0ZmEl98=Yfhxaf67?)_ zlF)K{svOhQR(mGCr3T0dq{FO8{ zwQ=)tjzo2;NZ{9cEEzmjiBmt!6B(7fncw-518NmbWxK(t>+o>mSF`{IyrT;1I_7M& zB_s|ILt$wLeumMhop>5C;iYsh+bo5r$|X`aqu;)74$f!)4~ny|i*)KiHYmDV*|Cz~ zSs5)!k!TLLNNnGS4Ld45N<~S@*seWxYU2b*z_U%pB@<8ok4tNRFF=mYVuB?D`*}LiWZjap>rHq@eH@T(6)Eiwwg44L|4GNmy;+*nT+oq#|S- zJ|T#6`~@pBH8nMW7Vubmb?G_dViODtu zD1t5j9nfnX4*_B0{zJho!1bIzDz(zUe!k9i@n7e?uK@m}rQIq=Ke4l#aUOZ=J1!+lzH~P`{pOY3^zu^Ld{D?P@;@4vKB@Q*% z@@)y5`Bva{h+gFE-qo+y-ygH#(Y^tqSEpb?QNj>WMsdS#X!@_{GyYJllkTK_Sljpz zhkuRQmAIp2nCT4z0(GE_B-&C*Ca}l-L`#9YU+?LLrX`eS%mgW{ytFf zi4Wd2r;IfjZ=3uR#j@sd9H<}v52iHGED+pmEnh-W=UoYkmZpxr!8CGWK~t%Mt&-U@Hl zB3Jrfr9t^ii`+Ne^`%5w8j`L>I=5pz>v#zubv(8=Jv^z*=_7P(X+}?fqszAO2KAcJ zzN4s$M#<*uH&xEnZ-;+Eg+}JI%{VAExXm?^*Q_>U`XQxr`rZ-OXn+}LryFcL0H`Rl zdh`WGZ#PFrqG|zmPn<{6Wc3h;c^p#|R0oc!!_$Vv-zjl%xmuj}_r7WJ#@-%oFethq z>~Qo`3)T)#ftr`Oh4nISX1JoQ1Zx6ix(={S`NOtcu=K#Lmq&Hlb%I@JJ^pnZ}q`p671#C+3_v?T;54R)^L+0#jPn z_)Vb3G$dlcWpQIMFA61+tDnDu7YX8uL5*i+r(Hq=>HG6;A3~lF(C5lFNdJ_}TY~HZ zNY@mAde;@Ftm`u`DU~5Gx&_E>Vyl-sFjx&w<{D9kJXKZA7yQ88W>BPXyykJZ-s4R~ zmh%#m{HOIg(hX5dD?~!{3QvTs*rX(9bjx9F85Y?FkHe_O{(rM`cdMo>f|BhKK|Hf% zNIf-hJi!DvT#!8$=_a21h~9M!u+2L{cfG2rYqs+XY?4@<$CZ}azzL(OA!xDyCrL6K zF7Dzm7)lpkUS4|DK$cJc?$iy{33&4zA&sr!-o?G0o%1%vD>F8`1hJrr)4ax>Xk8Ny zmN1+*c}7Q2(xg0C_6oF)H>`ObJr){dkM~M|a0A3$^KjU$mzEgU{{=`hRVM2LZaBbQ zAS#zChNhKd)r}j56hsW{%|7}&JkiQ~&>sY(EK*zR@hetMlBdzh7gg1b_AT$x@zCQ_ z!GXIB%Gt%!f!8%(H+=;YbNZqTLHzApd(n(a2*yVDvzFDkhu^$T|14gt-!u9yfL$|N zzaQLh9eMosM%Rn;r6&rAbEmc5XdwGMW*1>+NXH|>U!-xd)fi^s*Up$zx?Vu_ap*&MmmKGL2H#5RS3aS$(;Xx^~lmj<^ zwg2gXpZyEaS_6tM&_2gZT% z&v!Ql-FP(Fj=*b1M@3bNg$X|(j89=fpgQuWG-00@b3h5t`cbhU+6!{t7t?>48Y~SU&dt+Zke-4F0ls&qxGtZXU&tkcXD$~V!~?$? z4ppU~JmN;%0cv;#7f&j{9S_Z_K>4Fq^6%0+XB3+_q+A{JkNi|<@*u_mMID}q^*8gw z9_SW>^#i9IJ&wrRZs>a1u|}t09^#plNv}KjyulQ1hPWPlubWa|h5H*9oL(C?$|qwZhAKLY7?K#pU+ z9+htcN~ zl_vO5G*6boA2G;yBuDf#t^}y*4P{Q)-i=YK-hoF7y{RCIWxwCdt>#deIOn-jG_LE{ zO8sFp0t%A+Ar)@(Elh4aLADQq4-WN z?zzF53b5isIzJ3*f=^&<)gA|dEpO}x#sCo@4-@X%flLz(!uIZjE1JEK#$?&kVc~=t z0yTCQRPDhuz{kI0r3NW`LT=&5Sm#=a*giks^-oC1NJvU@GFofkw~@BlZ0JQ5nz@Mi zV_s>{Z;5y0**MmoRnC|32%{B*b^bjyLA_*VvTDy0<^bVVdf+qQTHFa`Yf;LqV`>y6 zW(*ow{CnM6$hE5S+F+-?LS;({9@%RB@rTqFyN}E~D^J1~NQk+&_+Gk5AiOv&F8#&_n7I`ANj74)cRdAO@&` zC5A5K)Yy~SZC0UZrp9B@nnKIGp#lPiNdnBa)jr2QI3^>jv#p+m)5Y7!YpPvb?j8gy zerQ<&P(ODUmnlb!)3>`TCnzLM+{MVJElv$Pp8@CIi@BdkErRM#eu@#_^4RqGgRIuC zkDVK>$5$8$8=n+(Kn8IdTuiL?x2q(i$Bhs$Dk4)}5)WlG(Q5&_QNm-XSAKpp#nLdHL1p^lgUg*X78t?sM-lI8)k$=hYr<=0)C$!onojh0T73S zT7U`#Ph{V`kOW$>PJT$-&oC=$A;>fX`kf++`xSChKpuCrGIN=)^n|P(WgpQ5I-2Ne z;j=gUCB3fC+|_E8Ui>mNlSJ^O1tVovAGk}PF^J%LBhLdMQ_k*3+3HW| zhah;=w&pBwcXMYb6Gv?fAwtf@U-18UI?Jf4{;dns-CfdM(%qqefOJcjG>S;6gmiaz zmw=QgD%~KBs7OeI(t;@PuJgZRyr1qJcX;){K6~%=TXW85rs&RvRKny;VfL5I@tI** zf#q>xhqYvKK=;5X!;>T25OcpnS z|6D~+0a@htZEY^0zu-t-PS~^ zXrs`NZTo8F$$$fW;^0o(b94<3A}ly3@u9MTsX~T5Y~Yq_D@}q6{YSIr~jAd zy>=rLtLhG+8klUvp=d_0P3xKqA7+1o& z*bts!%>>ncWmjMWzzGLRnnhj(4b&8eU>7G8p_QJMXn?aDR6`o$+Bry`5q1b4C8z4e z)o2MC)Ah=rdpyCdcYei6M?e{?s*w(%oR#A)M}gXPmO(qM$>2(zybu!{Zc%J(Jai=Q zwCAZ2$F@{qs|otNB8Kfr5O&<^j=r>1zcMel?_ zQ)W}E{Wq6<<)3^4mqC8LHo;o9eC^S?(3TdM09A)McX?4;X+x{b>M_C;vBI-ZygZhO zRH)24O*Kq+++Q?8(T*?c|L*P&=<)v6*1%3&hMSUmNmE2Qy6y_ zXdCE-px(fnwe_tgPyfcFXiBxWX`Krz6;aP;_n!RuOUZ9hwQgN-%OO(%O*9>DDpryZ zKAe>2_>4b>hv4BFfu$pXSMXSZ0OeVCLEk=4YDiWz9>(*~dQP=0xu>M%#JLZ-H*aF< z3q5sWtBc!u_ZoNwg#4-KzN6!!5pyZD8ozvoBo|B_-WaEZmPB)Y#}cO9wtcn#osMqZ zYhKI~_q60BqhVn16rmxbK_wxIzoWPhGer5lOtT-I@36J+0*c0$z(kFTN(%6)92^|! zW;8hAki`Bc+^C+S`EADs1E%J`J5Df9ME1g_0ZwysE{2QfaC1D!+Yh%EP~%(Y>r#Bl zA1)PcrrCq^d=DzgG}&{<*=O??YNMURPB z&>5A$3KK8wdPJKzn2o|6NF9&D`eFJtO zx;9HYDk8WPyl;STFgQ3E=u9^V72ga{S)(EXQ9h=U)ry8^KV5h3lD(#mza9JI?o}Jb)7r z1SS@GZT$h&s#^52%c{EO?L4fC*t zN-y9@o4oN_Mfg6%#KgA3ie7hTDeO+e|9z}cf!CG41B6y^T7iv$~wlp15@e8Xpy}sd!M*KHl4p!vzv$LzOAFs?o>T}eoHaL727Ptr& z_~py__TNFcdgu_l90-#82q**O7=>9hln|KcB#E#l@xN^WYUv!P6)+n3e)k780(4y@ zc_`!1D1S5|xrG+iZNU$r1K_|g61@Rv=k@uE8*%zXV{Y=#S)e=dj!fK%fNHR2@e znujMJ&#N~|bM%Lg0jBoR>f`hGgFl85<|y(fg`pl$+ytK2p0|vC(+dkvVWB4jtK0P$ zB10m|Np0z$r*J&ek{xuCZyyqwTJMc(0pl`M6njB%Eyk@*uuin_(>Z(*XtAgTo^F_F zQeIJZj0*9=d5fVKA2me321A_^d__5Lem`}a$OF0y(XF$C-Lp$L#)Ns;H$awj5|;hL zca6UcAUc)n8<(Koh$wN;-wAkFA~gAVc`qdw;N|BpipXKwj{fEY>>+(37?5FzE5P~u zK*0;juVA?}{7bQ{7dihflXCHv)rrutE&NI(=r9cLhroT&3lBBwJgFK&tCHQ~g-3#Q z|MYmu**ran+2m`|lc-oJN_kRk;-~>0s`w~2)gb6(Isha}Le(DrA%4Tut`SI2;YDpG zpt+XM8Pyu56AhSZS6~WeOHxXO`D(uvUb0s84v|cRGYhmKH|(c$dgHC!m|Fw)l8Sy^wNmTowEsM z-tHh#O}#D(&T&VPi@sC(FZ1F>+MgNC^X{hUKth^oE&kWg>2dyDf{Mg?~| zPG{koyZxC`XIQ?%5%RV19b9XV9HkfO$Sf$#vSj0W*(N_rx5IK5&TA+uM*F_bc(epI;}K?wP!>9?fPIyClDdJA;*=XfMG<+)i_g35;?iA}O zp5{FBkg`pK8ei6)w@bhDs#S91BHnmAku^IH4Cos_vT#p&C-kPl?yOuw`%9qy3KCjDFJmsVbn12Y8^g40Znv`f>+?PH7 zrku_Fh;7QV>k}O`x`9OxvnZ3^15TWf;W~n-0~~_kC?K(^I0Q>6f^TXPbnJyBY{xII zw_eP@dJtQgfn((CXpd6>fLh0gUyW)JC`n~^?FfGROh&Ji;dL+s4kqV)+5$PYSm&bQ z<`VEjSsLX?31DG@9u@+~8YB9sI};&m^j{ztaT8x%o}r?m5*33rWq5cP9-JVmK>{DV zhu_*K8GuFve*eethwf$Y!Y~etH~erVpSmTFk7xCskY;@oIrRjZ+4#BBV8b{7=TAaB zJZsd>tDm0Yscbay123)?Uch_;aj3&z!Lg9o%I@}jwr4JAm({IQ>;+)f$KQLUc8~n3 z(kvPlmcc2w-+40enX1~rrHx|WZCGJzL@A>wGP%os>e{`1DZ-K`JVnzRBFW$~1$+r7 z9l?n*P9cJR2~iYC2#bW{F0Ojby0n%iNVz~|GtR8NWin!k0}g${a3wT#>lC)UW5mns zFA$i1T@T7%dj|#4h2yamkP*Mu`VLR@JIu*xY&gDp@9t zE}>sy6NoN4Ma*0F1NOY%wrZ#=UBNs9VL;ezj8ZpvwwfX2d;77&UQyS18K?>rwgjjG zC=4oa5m*X;_qU2Zu*cw`GO0I0=*1p;PN0q>{`$hz$T^8IKiOg#Mt4wR|MP?*c3iKo zDu${n3r7Bq3fWPRY3Ay_G4Xm0pJDW;F9hty{Ei-0=5L+_C?=qXA%3aQ%M_QG7#4jT z@SY$NwHdJfa834MrO!V3YRO|6z9qbphLfc}$};rJ0Ma0dRsllM*xZf}toIJ!T>@hu zH@e!rw^9-kezUrFL22U_yHg5~kw$RhA}~UQpTZu22wJrge}^Sm4RNg(Snl8M6*ms8 zWTztl;{zgn^ZEH~xB*kYwZI#nooSdDz7qWXJpIni;u2=~FkJx~9 z%~4+U;qTG`MdfN6%RZD6{i>;bHGOaOFQr}-9 z?n9lr!!qcd-aFHH2Uq3hYWc=pySq~U>fsLms+S3`)IUiHi8ldfF0WLCv(ir+zi(>j zR6I;w@B+aJitZD>{xotxO_iM8IziOgI#}>$X4kcUIIBOrWh3KkW||Sn@LXcz`TI=> zBr)EUqgOku{O1Eyk$osvhyb+uE}G(EzoRK-`G-ckA63Qu!5E@Kl3qgZLU!oRNM2r0oe$&+mphIyLfg#zxMdtq8Sn%i zfu01H$mW)^|9PWoU`4D^{u&So-Q9{af!wO3?ttU`iTGZxc)=t8e$X1}ehuW2x+J$H z17tX~xYNH-X{S@OQN$(>gPbQIJo${8$C?Q-l}>dT)tj}#GFT&7soGWm-ISV!27%Y# zI}4~bcndHzm=v9M`rcBV%Phk)ZX0rGs>W|Xe$^Cd^Le+;)3XKI^W$wOtm`F^hVPWE zJea2Jg{tLxWx-^&^{KF-d$mqgOFWetD2cvGuzrPEHPYx3$Sk+b+cYO>X3}Zn?@+U4 zj-J7tO52nD)$`*mZTeT@O;W%iJz4}z8U3?U-L$O4XZv<9pK8YaCF||_>Fdz2A^tx4AcR}X-<^6L@(Eue zQJ>=6PL=HV2323$ilq*0N%!J6BW00(lC(p(9WhAP%^x*lqv}KEj*%J_a;kN=( z$Id?|`M;*Pe5u!X`1n(#D$YsN=&4A7r87Ic7H@Q93;C-YV1i7_kY#U$g10m{l>k>07}~+^kE>TrrVQp6 zObiT1&7KUK{gXcgau?Q8F!4x|Y3><&VlIF|y0+m%j?J>B%tKAn=0(J0?H|;pK~6HY zxxBC-{^I)I_!OqOC7bOQWbV~_KJ1_WwJAWB?kj+JnaXPqrLA_ZV#|ZipQ%>zK)o*+ z^Q+u$w2*tR+XFBu90O#pPg^IjSJ5+77&f#|qlGsBIggQoY_$xgVi&SkVnPBO{*mOt zP=}Dr9x8nK**5qCR68ZQ__(+v!WeAs}u7J~& z)RMyzaGQsQu-RL|Y$06FL?+w20i)Co)Fo-Ll7J`JF(ZECV5 zk!Un0tpt|gNT9$NJRSWZTr7@y##sci;?O+o*ev4wAQY)(dV8A_O^36!Y~d57#wKh8(bD#<(LG6OeX&nqMQNo<ifx9@@ER_t{6CMJ14!S;(a3a61vv~1(BiAg~TC)BoFeMqZ^?o3io_$ zZA*aIOoGjyip^K{yxwTkvDuyrk|IHJgnHUoY!-O7BN~-6@WRhiFBjcnzHly`^6Ibv zHyszoK8G!8fdjlia!U!@IyL@hE!0KM2XWwW-CDWtyCqJvHE(0}1$T|l_d{}V0cu(% z3?B5e^K(!j7ej}pI&8@8?W-Q4pHx(tm96`~x12=ZCeqNrI3=W`Gc*sTP7z8sy2p=QCZfQMJ)cT~20Ig8b6wsNFi-u3ajOKb36)$7}SKF8F3GoM0YaOeplE32eA zL#fEiesUB0+uN%=4BgIguEL|t2e&p|2yp&Vhr8BM|8hSQTOh}JA|gP+w8cr0nPy!s z)NWP}`RIL2*NHrLEuBuCQ1nA+B-hX9qzXGhFbw<@PPMn;0>Lpy2_NT`{oRFDNmH%0wbPla>Cc%QCOIUYHt7S z=YbrbrjTf{;3;4s(P-|wQ#@X#Q-8Mv)UBj~?gBgvxR`S;qDRfiqvFQmts8S(U-L2? zM}M<9XMnl(#}UZY*Susn%+g@L028OOfnu2Z-AU>3A1cneZovQ<;7(^}XIERybN@Sg zwIQtP<(=F5)xFIxX5mpDcnP~ch4n^XbK<`;s^w}W<5AMK;_sRpB!0Dv?6(KF7Xb%e zGMw{L+?Hn9lH`rg&tfBlW zp&b@uiQ7E?3)BJeW%*onPO=fl=J-3xaqUY3nEGO{8W*DW;8U8T4*zD#XdlGhGg&Vm zNBbj;LPAn9`bx62i$S$9eAXA2w7>5RtOJ_tVS)W9is06T3=y<3_uDv(tCl{^TNr^X z>yAJvNN%AVeXL-15ap z?3MnV#_c)CV+PN;9J+&IO3S!;u!ZBc^}GUIGD zZJiG1)R*_HPk~SLI44!XE$mw;K^O&*9eY6Yf0U$3+7e<`46Se>DIgKqHGPJ+hU65G z1|tRS07MCyIq1ZW?1kosGzl_Ifj@$(o{7j9wl8P$UzLmqZ(7_|yWhO1$)39HLf0jb zx=r0dSw#Fr(GX>bl@uLCi!Ny;<&R_(fvev@8#^a|mNhIaCsZ zMO(4Y`961v?CL*O*Ww^ngK2Gq(3r{Za=qcp0FC|CvZoyuASL3~&|2T=O=!njRuK?slDXEdK_4SFE_B`K%IN1>s1p?V&AgGwV zHYtL=@7?5<%Q~WD9fnrat9NHdIG|Hv--(So43f|~=o0%IOUT+R=kjuVIu{bbh6-I{ z?^9PiGPM&46bC{H)DrIVdx+3f9q27MXIoC$$g`HqI6NSQGVsccGuWJQI#Rqu0dtYno95|B2 z`DdRy-fHs5Q!~rJGk9;=&dD2fEwvFzfyLl%`nuaM_w5;ju%598o)iBk+>dn2CeDS9 zQzYd@CV_^aD*YHfV{m` z6d_0APwGYXD&lxD5-FUm9`@eg%+oP@b;yJE(S0{rOo2oRoGd-BOy}?ByzQ*l_})J= zfKnv-$_Y<&fwQ_;XK2+6Eld%QNt4)vRn>|ZwK|}yVed?;Z&!tcF^t3xZ>(NV`42Ce zWLcHKn(Hg|GxkZreytDguRc+S|FH6DalQw0=ehPaI-~5`Q>6`nk@Oj2{d)MiiZVnD zLne-kbUAHSRKz+}1=r!$3z=U|NZZ?wQMMWhgM%@XFl<>7(xa*VLh){R2IdV2Uy~B8 zX7~>pb2E5@B~VqzMRDD+53$W|H_#uz%W{K&quJ{Fwue7Kr!;Hd%r(Yp6gRhVfT|zM zThm+4y`8z-c}}ZQ5Qouhr$}iHgB6g%SP^r;yt?g|4W%dJgVy$tP&GQ;T7n#X)N@ds=s+q=;@9!eK=ZZK^Ud zB0?b(HcBeYA+Hn6?*A6x8l_{RgW~{-b9@wWxs<+z`n7SRWfs~?o$dSZOAA-prt{JC zFYF;$y-TM3AlYY#f|~T(n38rXdUxnlHC|PwB2tUOfGQ-;VU=E+OXm>I)zwnuQ{(+X z{2`{djj+jOX(*c6<%HBBK|lvexnSnU9S_ zO`j-3-IlQ;)uVHNTwYsIr9NaKroYYRFI*b1a%ANP=YP&q<6+a&mF9?7<<4~v^xR{5 zHmfw{bhRg%Ed9~k@?|u({YMhq#kdl-%A{KlWhm_5;h>4q;FCR5bsMv>=^`f=5p-K0 z-VVr+8jb!;Y$KAC2VNo~cHB|}?&0SwPxE}?Hr^yv)a|%ge0`{hbt}?X=~k|?FaBBT zQ;kJA8j3u{lJ7aSf4>}a|Htak53i^2^agY9XDS;JS^E0|RQA$eF^a$X(BkLXAyIx9 z-B2n8X%7%&jz{#i=$}>dl#7h}Wl{bq$Ed!yyekT`m)~)ea8YFU7%8}!R$(IO&%yDg zUwPEo*_g9-PR?`%Rvd|zDyKMqP%GiBSR+4SN6_!sjJ?6x7yBP^986pZ>GRys{uzf959p6>Usx(X;{`H7aXz7nkY1L#wj|V}d9WcU_HlB1?0E}SCqc_Y= z&qkY4cTgxy>76Odh&``6UWAH0Mu1n{5p}rU8d`#8e|xSqo8Ap8O}tdTwnnH=a}KNs zCMSuE_8ZUb8q(dZHT?KfbXzp!LPyQGdg+68&Q!PB$(t z&YAf2)oUN}X_$7+z7t7t?MAgSYBzR}q`3ICYRPZ}-foDP-q`@u9-b8&wx}_UpsM|Q8`%k zj+^PSn&QNcIUu}*t$tMoBi96#;3C}|W6_y(7WoZQAnc5CyO&{^O#m%KpoTrygQ|uv z#dI6W1d?byI+>%QF#4~ptZ~Cyh=d$lULO8ht_T>RmQO3h(ZM0m3Fa*)Xqo*5sml`p zOVd5S0JlRv3FiOule#i!^=eL3muuj_OkmTmH@Z1uxS2v@<}nI>xb7tp5fLHrZ~Gs3 z-2@TB%+V)Z z$d~}ngVGsrB7ekrOXkTuumv4?FhYYAUOTtTRhd^}M!Kym@k96V)KsG6CY4|*!{aeQ z6=O{=VRRxgd-#!2gPR~ihNvUTtZBsWX^2!}`d|ei4!TkV?CRg26Vr0$ej(u@(AR%M z86j+sG;;uF6cgz3b|qU5O5FRSpo5Y3yOqPoqQnTL;u&ped|}5s$Y`ywEh?o1a288~ z1j}mZa&+ec$^uZ5+N^2rs zzqIN_2cOmXQ;mmB@figUx~c*A=v}S;n%R7IOoN>p><8lNz6S z8=i_jBN3ulz~(1wRXI_{G#_*FQ116rcBy%1k?(NPs;>qAjJx@G?ZB=XVkte?9E}8U z6d@mHbWgQWe}GPBIomAELQ7N9kpdGtH8?zNy#n5NMiD)_jVy=gs*>z%o|miZkc;thu5fQ6UHl_ZAWp|;dc8TgZ% zA_tDT>a9Lr=N|-BqKs@;01P#Bu-p0&?9F1^HgC{xPAlba0U!)z9qG#|9vF*wFx#{CdWik=cd+2@+IA_er1|aAdc5hTE0MoGN<^%OkJ{EA#@;DS>o30oorzS0 zm^d9`5>Em1uXJ~XFhlqWXgIK6JQ)A|@i@I9OOw$j>;)VpK=5dLw4w28-M^huH{C)c zaAg9qL*(>yqwqEK*nnQsEBHL9eqH(gP|?L_`dTHd^emX*DYSVhu&Kn=PC0kN{yI+& z&3OBby^wz|zIah;$eBPsouyHvBJ-PTA=zD;cvBR zb~r-dn_}mSyIr%3d{!iW>z%fmLY8;?lu<{M5hHs zKH`7<2+})Of2X*~SM#Tc!ol-JCkEZ?`A zGPB+O@ydPfc|~u{zMP1N50sVa#lfCMfET_PN2dm!p?HwvL4Zbf#=t>VIpcrSPYGR? zKJL$OxEN2iKHW?_{U9tSH(~z}+dYAKaO~jehm%`FY5r)t+S||MfyvR<@N6u@U8`*h zO~<_LHdOi;m|MK_<)d3|ECLD_+`=;RU_764_Ax&hB5{Z~*@7#S4~7pQxMIKNHX+4A zi-?Q_ElIABgI$iNJG%vt+R4euV3daq29hLDL#TA2Y!Y}~==#JEC1>osSc^^DF@=Al zUrREnXVt4%$K*p$N0f#2*adLffe`~0_ffZ2)agQC2MO|BoQ%k8=(d8?zK>Ks{Cyz` z)^PBKy$729$UA~ENHTItQC8sRhn_bA z2`xO1^=P30c+_zqqtVSl)h^sl!IgR&yE+9_|MGpmM5BLee-MqLC84j#3$}MCZH3_S zZ(tsaz1$}t8JEtVfSuAsg!R2@gG(^I0N&KL397+i4MUbefQAB-L%K$jn2NMH^u>Yg zAFP|-QI*c2fLx6gh7b@+pW;1u;<-;gY%dggBw)q6gD`fp8cgc}VF*mi>hCxN*dXcx z5*+Add?C``t8JU!xXjg1V@5K<4}Nd<0^_m0Br&!{SI!~O@Nvn+RZFI}r_1l1aVP_1 z?)DR&Wk_-(ZX@m&AtT^){CsZ-8V*+Bfh7X& zw9(M)qcw-o3`K254U0L9ChGvh`8Uf4FW{!^7ZH5`ug?2ooITL_vWHL{dctvs-E1tg zivFubqTsg}n(s!)QN4unaPf5pv%Ra_;6VW)YVMCnJX;WkV9)7h@1iLU242BMByl)nZVOm-Jx}Y>BR|Mj6j`2ac~=HBYSrd z^dgB#eN929%VTDTrTrWKs{5LbS--G)=#BhZOg5x(M78zVPu>oNO0a7Q$%*nmi@L5l zMV5!5MW?~*DSwd{p$3UZpvTA#r=y{n^>jzUi;$f?+oSbH?h9*rKc6wp?@8Ut)ONR; zVA&7jSrFHzeku+6In&K3YfN<*p5H!m99;{(?(w-!ifwHay=ilz((MY};4=+9G`Fsm zTCxZdjF+oo=`y@*;|a5hT=_soaichSF)rAm*5A#o?|}CK?dhw6ER9^VP1?E6W(t}G zOZ$2r;j*{yT8o|AWK=zKj{@>=FZDl7|KyrIPCIJyL({)ka4q&E5bamF9{JK`m}F(- zQWh)SrSkn2cE0ERqBNv@Zb!X2t&!u&ndr=h&qqwAFqa+}^7>dEvDjdEC56j0jJDz6^Q^ub!V$A>XM_+ z2)gx5diUbigxn*Uj53X+BF9fv7z#YS}x2r2foskhYkFqrg8vq0vACCP)L~`gfy$E-T}!Px_#>!l?mxv1KvM}KNlAt50Vzx zaDe-O94!R&Vd)2uihX9T0p$oI={z4V=Nb|c5l|f{NlqgeDFvD<%u|XjtP}~BV;p}b zwC-;q^iS}e0K#$TasQWJCx77SO!?suop?_9RK)JD^E8q`*%}CC&`kCtUi_6w6AC_R zjQMLNAm+kD@&icu)xz(`7vf5iP(Gk4;m^68*)jYzY4`pPRuQ?=V!~pSnD4W;Y8~kO zPSvvruVK?<7FnD-R@8k!{z6n{y5-LGapuP$7=sF%yent^gx?M-{`j#_0Qaf`E{?~? ztKZ;}fteCw$cE8)LxYH%+FbGuKpMQx=8S90Xnq#L+fj(GVo04M-v=tqT@?-^f`D0i z;3)U(a;DG{&I-k>d6(w0Nwo?L6jOKQj+5an_gj;kerT>h-1GZg`Agj>YO-m`y}D+g zX+5OnR?pV11Oqc7+=R$EgM4fA2@Xqnr5p&?cfYd;j-hUcg(VE$;5&Hdw)h%C)Z>o+ z)eKh+y*@9*>2@Q80Nl2Av@HwIK0#&TzM#J z3JxI`;MA0dkg`sAmQOh+&Pzq8pzvHOvEWC5;Q((y@Z5jdCY|YESHJL*Yz$BmP7roy zVfcfk?eHqIGf?4pO#PYwb z0^I(j7z`%E_n(tg5_eKN%RKr=NI-DC_wl+`=*E#v^A{Yc!WyEIWSqI_n&6u+{Cqj! zeZlAk9QO%-u%>z#VW6wY!jUsWk?<-zFYn)&lU$e!Zl}f+y`k16c-my2If|!N5s9)5#$SWzpHrlW$uaw4x<#4VZ3p(QR=g zQ4PTD=AEI=9r`Ndjf z5g}6?X%GqZ+K(6jrf0zIkdmQ9UX$tovm-#J>X7%#Ay^jAGkzG`Mog8Y#&+N%bzk<3 zEa=^)f+j$B1(UzRUO%6>Qv5!ym_abPk~VCq3Bf|U5}#%%>v~;Nj0k6*UxAvWGne(A&+Z)|V+SU5#5b6=#w@JAB-QRRa9kEn z)@F`pOo-{Idg&)tuEtu@a-hQ8O&ckAUz94{Vm72i&nJbap)=*PXaur|ew&T;=x)%1 z%hB^7O$n*zUGD9?&)_PDJjMb_quSic(w1==V_s8Bs^tq_bGc^pBW}}+>t}LS?5Q@7 zASxq5)7q7sx;lBp&ck_A-8)zRtcLS2uJf2o=)m{AS3$X+I+x*mHCC?S zX9bh^TH^+GgYt7@)l(#58~Um|finhBmg6WLe+p9w^OpZZGZT*Ng(ed}Hrxg*`C=ay zZ(VI18Kby3lA;ijBht7nrviTr9R||(lE;V;`Y0ksDTF!g(h3v^Yi8!HtW$8`M^=Oe zM*K+96oN7fq_i+ens_~X<5oB5z#7}O7STPQLgmz~4XrEEws-MRdPlxC2D*$lrkJI z96cv|Ts}}0jv{{-c-W10D=Tq{q&w~vXEcs((9qN0`BBU$xSY$-A|@75=uL{QBlJqk z=m8i@G|^SKp&E?3e8fXICWXb&E$c0H9U9}p*zK~CH}XK-+nvB@et^CfdEZD3sr5m% zE&G84ofc?3duK2(%!t3S+Va{Kp&6;}`ckMHoXKgEPv;uDZ+^UD8e)J3xCA%G&1YvE z=?mM@^!jtl6|oC@Kx4d@$*=ktKKP#?%ZEoZ=+DwF6oj(LqLtdWHhx(^N0m342~~z= z1Ep1~#0!;T;b!T>^cu#oC$)37)NcK}hH6RvyRbDQU10ulhZ>pBQ|CVWhuD}WbzT)f zOb>1N0b4u`0j?i+yVydoyK^{}A+LW-`o_CEovZah2mT0gcU$>CvRkEw0^SCqer z;2Z7uALEmoGd2@_3m&O9qj%Kdln%doc&=xTMiZkP#LlJ#$rWm{BZrC6;CH4FktSb6 zna93%?qX8o4)!ML(U|a?=?cCK8-GwnaZw)J;O08>sZ`K|{j9ah+8#-bP>edyx~S5n z?(_Mi9TZQt8>r&Kjl0e-e2eqGrapvki51;NUAL76!O8+nqBxP9#eLm?7zjAjN>PmB z?h@w|hZ0WRYQIQaH9s~~`i48DFP>hECOnVkVyK&-Azz)wj{!Acv z$`v{hw!kZ}%?j}%6jQhEGH6LFZCdg`b!AeE-+-1Bcx_4sC>EC1m5LG5!JrQJe{}Ae zr9CMo6Kbq{WoUxz@xb8|8!E3?kN2zPcz!)r71+$v3z!KPQIuslWU?Woa^KdH+{H>i z{0do+$7#z@s;q!+EpPC^&nWa=!X@3B<0lB5Px)pwQ+VGVZfq`o?@2z-U|3^bNjIe;fFk$=X>i+v3qmM*F3YsV45BH1R^{}iEf%8}iCUAqG#l7e9$nXv$y^E=PTTHD9_D5? z?bNG2^euXTT(;_cI9b~_2iGHO+jhE7utA~3v=ynFs;EFvMrq!RGB(}^{TnGe@h;g( z7oC@wppVMlSy(`gJaxH86UylVNO1TSE0*HjfuR8|n}SbW3-RZ9C(13yIqO8`QAyuF zL9N{0*z{@qNqW^gMuAWKSB65Oi*H5_O{U^?Kuuc+2Rf}eE&4gLWXMmlL;5871g22b zI=0_>1iW$z^jd%WR3ElsW=2A;759G-Mo3%w(Sz}7EQnXTTB#W7p@+z|*;9edbeaV@ zR@gv2@8?pxFdR;z>vx^SL@Kg&S$v%yNkkCoZC2V8!u3VW4n6Ho&uz9J zIOXw-(!-CzYGiNUH~R%UeWi7cgUXn5nQ=C7n=r?Nm!j5y8&o+9{?wEZ)Wj0`Z^=?` zK0m~hcHtzuM(MNbwSI7#1d^{U)rzKSYk{I~cnYsJW(UIw`O(ev92eJ;4Ii5gqc-9VXDBl%o3pKlR^~k&-0_)k=jP5u zT4K-7w`Y1ZNnAAmkr`H!VoS`WfsWd`Z`9$>6o^$b@Rv0S$%dL4HJF)o=NZ|8ZtOn^ zYf(htT>BM%;^?PT9x8dZFD}P}@p+t^Q6l&@T_&=_?7IhvKl11V=s#lmtERAL8+GA z`;H}#A|oNIV)3qf8%KT@R^?6Zjb6oYslxtusft{=*%P+1K1&T!**`9$8MGB_9suto z9jt$c!hfdEOH@_8!!7`yeYhpD4s)=a`X4QBwb|8kvr!m>daYtCiP%(akYAh{wz?xn^{sn8Mab+k{;KvVa9i_K1rU!s(JZ*pM)lQOV! zrkI_QTY8bjueAdpPG5>51C2^1nNR%b{7fDBVR=6O@!-bW^fU| zcySZQ$nYoSi022#rgHo5Xz@@+C5IM9EWH

ZP+Jkvkd2AZpr7=oW>cPyP%jsVfpB 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 z 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 literal 328393 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

-rHL2r@xgoMiKPnAFXZ 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

+AF*RN^v+76SB~Nd+i;7_`X?5UwPOZvn@pg< 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 Date: Tue, 31 Mar 2026 14:48:18 +0800 Subject: [PATCH 50/72] feat(provider): add lmstudio and align local provider default auth/base handling (#2193) * feat(provider): add lmstudio vendor and local no-key behavior * refactor(provider): consolidate protocol metadata and local tests * fix(provider): sync lmstudio probing and model normalization * test(web): format lmstudio model status cases for golines --- config/config.example.json | 4 + docs/configuration.md | 16 +++ docs/providers.md | 13 ++ docs/zh/configuration.md | 16 +++ docs/zh/providers.md | 13 ++ pkg/providers/factory_provider.go | 124 ++++++++++--------- pkg/providers/factory_provider_test.go | 86 +++++++++++++ pkg/providers/openai_compat/provider.go | 25 +++- pkg/providers/openai_compat/provider_test.go | 10 +- web/backend/api/model_status.go | 22 ++-- web/backend/api/model_status_test.go | 50 ++++++++ 11 files changed, 307 insertions(+), 72 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index ff2969dcb..814c82503 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -48,6 +48,10 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" + }, { "model_name": "longcat", "model": "longcat/LongCat-Flash-Thinking", diff --git a/docs/configuration.md b/docs/configuration.md index 61eb24b14..58930cbfa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -563,6 +563,7 @@ For complete documentation, see [`security_configuration.md`](security_configura | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | @@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' +

+LM Studio (local) + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. + +
+
Custom Proxy / LiteLLM diff --git a/docs/providers.md b/docs/providers.md index 43cba55b5..f45aa5f3b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -56,6 +56,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | @@ -226,6 +227,18 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' } ``` +**LM Studio (local)** + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. + **Custom Proxy/API** ```json diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index d4366b6a8..a405df09c 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -365,6 +365,7 @@ Agent 读取 HEARTBEAT.md | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | @@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
+
+LM Studio(本地) + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 + +
+
自定义代理 / LiteLLM diff --git a/docs/zh/providers.md b/docs/zh/providers.md index d19dcdd89..04b2f7a88 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -53,6 +53,7 @@ | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | @@ -211,6 +212,18 @@ } ``` +**LM Studio(本地)** + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 + **自定义代理/API** ```json diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 962e6ae19..e956db209 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -17,6 +17,48 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/bedrock" ) +type protocolMeta struct { + defaultAPIBase string + emptyAPIKeyAllowed bool +} + +var protocolMetaByName = map[string]protocolMeta{ + "openai": {defaultAPIBase: "https://api.openai.com/v1"}, + "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, + "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, + "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, + "novita": {defaultAPIBase: "https://api.novita.ai/openai"}, + "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"}, + "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"}, + "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"}, + "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"}, + "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, + "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, + "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, + "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"}, + "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"}, + "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, + "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, + "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, + "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, + "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true}, + "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, + "avian": {defaultAPIBase: "https://api.avian.io/v1"}, + "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"}, + "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, + "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, + "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, +} + // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") @@ -154,13 +196,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return provider, modelID, nil - case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding", "mimo": // All other OpenAI-compatible HTTP providers - if cfg.APIKey() == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase @@ -294,64 +336,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } } +func isEmptyAPIKeyAllowed(protocol string) bool { + meta, ok := protocolMetaByName[protocol] + return ok && meta.emptyAPIKeyAllowed +} + +// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests +// without api_key when using its default local endpoint. +func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + return isEmptyAPIKeyAllowed(protocol) +} + +// DefaultAPIBaseForProtocol returns the configured default API base for a protocol. +// It returns empty string if the protocol has no default base. +func DefaultAPIBaseForProtocol(protocol string) string { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + return getDefaultAPIBase(protocol) +} + // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - switch protocol { - case "openai": - return "https://api.openai.com/v1" - case "openrouter": - return "https://openrouter.ai/api/v1" - case "litellm": - return "http://localhost:4000/v1" - case "novita": - return "https://api.novita.ai/openai" - case "groq": - return "https://api.groq.com/openai/v1" - case "zhipu": - return "https://open.bigmodel.cn/api/paas/v4" - case "gemini": - return "https://generativelanguage.googleapis.com/v1beta" - case "nvidia": - return "https://integrate.api.nvidia.com/v1" - case "ollama": - return "http://localhost:11434/v1" - case "moonshot": - return "https://api.moonshot.cn/v1" - case "shengsuanyun": - return "https://router.shengsuanyun.com/api/v1" - case "deepseek": - return "https://api.deepseek.com/v1" - case "cerebras": - return "https://api.cerebras.ai/v1" - case "vivgrid": - return "https://api.vivgrid.com/v1" - case "volcengine": - return "https://ark.cn-beijing.volces.com/api/v3" - case "qwen": - return "https://dashscope.aliyuncs.com/compatible-mode/v1" - case "qwen-intl", "qwen-international", "dashscope-intl": - return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" - case "qwen-us", "dashscope-us": - return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" - case "coding-plan", "alibaba-coding", "qwen-coding": - return "https://coding-intl.dashscope.aliyuncs.com/v1" - case "coding-plan-anthropic", "alibaba-coding-anthropic": - return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" - case "vllm": - return "http://localhost:8000/v1" - case "mistral": - return "https://api.mistral.ai/v1" - case "avian": - return "https://api.avian.io/v1" - case "minimax": - return "https://api.minimaxi.com/v1" - case "longcat": - return "https://api.longcat.chat/openai" - case "modelscope": - return "https://api-inference.modelscope.cn/v1" - case "mimo": - return "https://api.xiaomimimo.com/v1" - default: + meta, ok := protocolMetaByName[protocol] + if !ok { return "" } + return meta.defaultAPIBase } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index f1fe02cc2..588b81650 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"lmstudio", "lmstudio"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, {"mimo", "mimo"}, @@ -153,6 +154,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { } } +func TestGetDefaultAPIBase_LMStudio(t *testing.T) { + if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -173,6 +180,85 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { } } +func TestCreateProviderFromConfig_LocalProviders(t *testing.T) { + tests := []struct { + name string + modelName string + model string + apiKey string + wantModelID string + }{ + { + name: "LMStudio with API key", + modelName: "test-lmstudio", + model: "lmstudio/openai/gpt-oss-20b", + apiKey: "test-key", + wantModelID: "openai/gpt-oss-20b", + }, + { + name: "LMStudio without API key", + modelName: "test-lmstudio", + model: "lmstudio/openai/gpt-oss-20b", + apiKey: "", + wantModelID: "openai/gpt-oss-20b", + }, + { + name: "Ollama with API key", + modelName: "test-ollama", + model: "ollama/llama3.1:8b", + apiKey: "test-key", + wantModelID: "llama3.1:8b", + }, + { + name: "Ollama without API key", + modelName: "test-ollama", + model: "ollama/llama3.1:8b", + apiKey: "", + wantModelID: "llama3.1:8b", + }, + { + name: "VLLM with API key", + modelName: "test-vllm", + model: "vllm/Qwen/Qwen3-8B", + apiKey: "test-key", + wantModelID: "Qwen/Qwen3-8B", + }, + { + name: "VLLM without API key", + modelName: "test-vllm", + model: "vllm/Qwen/Qwen3-8B", + apiKey: "", + wantModelID: "Qwen/Qwen3-8B", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: tt.modelName, + Model: tt.model, + } + if tt.apiKey != "" { + cfg.SetAPIKey(tt.apiKey) + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != tt.wantModelID { + t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + func TestCreateProviderFromConfig_LongCat(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-longcat", diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 90bc683b8..aa9473731 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -42,6 +42,23 @@ type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout +var stripModelPrefixProviders = map[string]struct{}{ + "litellm": {}, + "moonshot": {}, + "nvidia": {}, + "groq": {}, + "ollama": {}, + "deepseek": {}, + "google": {}, + "openrouter": {}, + "zhipu": {}, + "mistral": {}, + "vivgrid": {}, + "minimax": {}, + "novita": {}, + "lmstudio": {}, +} + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField @@ -397,13 +414,11 @@ func normalizeModel(model, apiBase string) string { } prefix := strings.ToLower(before) - switch prefix { - case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", - "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita": + if _, ok := stripModelPrefixProviders[prefix]; ok { return after - default: - return model } + + return model } func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ab632ccf3..823b0ff28 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -432,7 +432,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin } } -func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) { +func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -474,6 +474,11 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T input: "ollama/qwen2.5:14b", wantModel: "qwen2.5:14b", }, + { + name: "strips lmstudio prefix and keeps nested model", + input: "lmstudio/openai/gpt-oss-20b", + wantModel: "openai/gpt-oss-20b", + }, { name: "strips deepseek prefix", input: "deepseek/deepseek-chat", @@ -579,6 +584,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") } + if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" { + t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b") + } if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index aeef85119..2269147df 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -10,6 +10,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) const modelProbeTimeout = 800 * time.Millisecond @@ -60,10 +61,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { return true } - switch modelProtocol(m.Model) { + protocol := modelProtocol(m.Model) + + switch protocol { case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": return true - case "ollama", "vllm": + } + + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { apiBase := strings.TrimSpace(m.APIBase) return apiBase == "" || hasLocalAPIBase(apiBase) } @@ -81,7 +86,7 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool { switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) - case "vllm": + case "vllm", "lmstudio": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) @@ -100,11 +105,12 @@ func modelProbeAPIBase(m *config.ModelConfig) string { return normalizeModelProbeAPIBase(apiBase) } - switch modelProtocol(m.Model) { - case "ollama": - return "http://localhost:11434/v1" - case "vllm": - return "http://localhost:8000/v1" + protocol := modelProtocol(m.Model) + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + return providers.DefaultAPIBaseForProtocol(protocol) + } + + switch protocol { case "github-copilot", "copilot": return "localhost:4321" default: diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index df942a9e9..bfeadf1fe 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -35,3 +35,53 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured") } } + +func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { + if !requiresRuntimeProbe(&config.ModelConfig{ + Model: "lmstudio/openai/gpt-oss-20b", + }) { + t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true") + } + + if requiresRuntimeProbe(&config.ModelConfig{ + Model: "lmstudio/openai/gpt-oss-20b", + APIBase: "https://api.example.com/v1", + }) { + t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false") + } +} + +func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { + got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}) + if got != "http://localhost:1234/v1" { + t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1") + } +} + +func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) { + originalProbe := probeOpenAICompatibleModelFunc + defer func() { probeOpenAICompatibleModelFunc = originalProbe }() + + called := false + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + called = true + if apiBase != "http://localhost:1234/v1" { + t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1") + } + if modelID != "openai/gpt-oss-20b" { + t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b") + } + if apiKey != "" { + t.Fatalf("apiKey = %q, want empty", apiKey) + } + return true + } + + model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"} + if !probeLocalModelAvailability(model) { + t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true") + } + if !called { + t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio") + } +} From 31fcf5529732948d6ec633b2f072ea8af5a040fe Mon Sep 17 00:00:00 2001 From: Cytown Date: Tue, 31 Mar 2026 15:06:47 +0800 Subject: [PATCH 51/72] fix linter (#2206) --- pkg/providers/github_copilot_provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index e2d1d7d98..472c14257 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -41,9 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi } session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ - Model: model, + Model: model, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Hooks: &copilot.SessionHooks{}, + Hooks: &copilot.SessionHooks{}, }) if err != nil { client.Stop() From 61a31df168e2607d246600dae1b89d912ea98aab Mon Sep 17 00:00:00 2001 From: LePotatoGuy Date: Tue, 31 Mar 2026 02:11:17 -0500 Subject: [PATCH 52/72] use explicit port headers before falling back to wsPort in picoWebUIAddr --- web/backend/api/gateway_host.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 6190f0c7c..f8e8eadba 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -190,12 +190,20 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in func (h *Handler) picoWebUIAddr(r *http.Request) string { wsPort := h.serverPort if wsPort == 0 { - wsPort = 18800 // default web server port + wsPort = 18800 } if fwdHost := forwardedHostFirst(r); fwdHost != "" { return joinClientVisibleHostPort(r, fwdHost, wsPort) } host := requestHostName(r) + // Use clientVisiblePort only when an explicit port is present in headers + // or Host header — do not infer from TLS/scheme, as serverPort takes priority. + if p := forwardedPortFirst(r); p != "" { + return net.JoinHostPort(host, p) + } + if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { + return net.JoinHostPort(host, port) + } return net.JoinHostPort(host, strconv.Itoa(wsPort)) } From e4893d27d769a8441d2d2ac491670158f696d327 Mon Sep 17 00:00:00 2001 From: Cytown Date: Tue, 31 Mar 2026 15:50:55 +0800 Subject: [PATCH 53/72] fix test and lint in web (#2219) --- pkg/providers/bedrock/provider_bedrock.go | 5 ++++- pkg/providers/bedrock/provider_bedrock_test.go | 12 ++++++++---- web/backend/api/gateway_test.go | 1 + 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go index 9ca29455f..3798c5fd8 100644 --- a/pkg/providers/bedrock/provider_bedrock.go +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -208,7 +208,10 @@ func (p *Provider) Chat( if err != nil { // Check for SSO token expiration errors and provide actionable guidance if isSSOTokenError(err) { - return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err) + return nil, fmt.Errorf( + "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", + err, + ) } return nil, fmt.Errorf("bedrock converse: %w", err) } diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go index 882c2971c..38a5e26da 100644 --- a/pkg/providers/bedrock/provider_bedrock_test.go +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) { expected: true, }, { - name: "full SSO error message", - err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"), + name: "full SSO error message", + err: fmt.Errorf( + "get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token", + ), expected: true, }, { - name: "SSO token file missing", - err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"), + name: "SSO token file missing", + err: fmt.Errorf( + "get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory", + ), expected: true, }, } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index ca6639f67..0ef027490 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -68,6 +68,7 @@ func resetGatewayTestState(t *testing.T) { originalRestartGracePeriod := gatewayRestartGracePeriod originalRestartForceKillWindow := gatewayRestartForceKillWindow originalRestartPollInterval := gatewayRestartPollInterval + t.Setenv("PICOCLAW_HOME", t.TempDir()) t.Cleanup(func() { gatewayHealthGet = originalHealthGet gatewayRestartGracePeriod = originalRestartGracePeriod From c783bab2d7c276c784b4395d06dad7e63d00970c Mon Sep 17 00:00:00 2001 From: Aleksandr Bortnikov Date: Tue, 31 Mar 2026 10:53:24 +0300 Subject: [PATCH 54/72] doc: added documentaion for use_markdown_v2 --- docs/channels/telegram/README.fr.md | 33 ++++++++++++++++++----- docs/channels/telegram/README.ja.md | 33 ++++++++++++++++++----- docs/channels/telegram/README.md | 33 ++++++++++++++++++----- docs/channels/telegram/README.pt-br.md | 33 ++++++++++++++++++----- docs/channels/telegram/README.vi.md | 33 ++++++++++++++++++----- docs/channels/telegram/README.zh.md | 37 +++++++++++++++++++------- 6 files changed, 158 insertions(+), 44 deletions(-) diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md index d9ab0644f..17a73ad1c 100644 --- a/docs/channels/telegram/README.fr.md +++ b/docs/channels/telegram/README.fr.md @@ -13,18 +13,20 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| Champ | Type | Requis | Description | -| ---------- | ------ | ------ | ------------------------------------------------------------------------ | -| enabled | bool | Oui | Activer ou non le canal Telegram | -| token | string | Oui | Token de l'API Bot Telegram | -| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | -| proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) | +| Champ | Type | Requis | Description | +| --------------- | ------ | ------ | ------------------------------------------------------------------------ | +| enabled | bool | Oui | Activer ou non le canal Telegram | +| token | string | Oui | Token de l'API Bot Telegram | +| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | +| proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) | +| use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 | ## Configuration initiale @@ -33,3 +35,20 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun 3. Obtenir le Token de l'API HTTP 4. Renseigner le Token dans le fichier de configuration 5. (Optionnel) Configurer `allow_from` pour restreindre les identifiants utilisateur autorisés à interagir (les IDs peuvent être obtenus via `@userinfobot`) + +## Formatage avancées + +Vous pouvez définir `use_markdown_v2: true` pour activer les options de formatage améliorées. Cela permet au bot d'utiliser toutes les fonctionnalités de Telegram MarkdownV2, y compris les styles imbriqués, les spoilers et les blocs de largeur fixe personnalisés. + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md index 03c48cb64..09209cc3c 100644 --- a/docs/channels/telegram/README.ja.md +++ b/docs/channels/telegram/README.ja.md @@ -13,18 +13,20 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| フィールド | 型 | 必須 | 説明 | -| ---------- | ------ | ---- | ----------------------------------------------------------------- | -| enabled | bool | はい | Telegram チャンネルを有効にするかどうか | -| token | string | はい | Telegram Bot API トークン | -| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | -| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | +| フィールド | 型 | 必須 | 説明 | +| --------------- | ------ | ---- | ----------------------------------------------------------------- | +| enabled | bool | はい | Telegram チャンネルを有効にするかどうか | +| token | string | はい | Telegram Bot API トークン | +| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | +| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | +| use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする | ## セットアップ手順 @@ -33,3 +35,20 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ 3. HTTP API トークンを取得する 4. 設定ファイルにトークンを入力する 5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限する(ID は `@userinfobot` で取得可能) + +## 高度なフォーマット + +`use_markdown_v2: true` を設定することで、增强されたフォーマットオプションを有効にできます。これにより、ボットは Telegram MarkdownV2 の全機能(ネストされたスタイル、スポイラー、カスタム固定幅ブロックなど)を利用できます。 + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index 86c016a5d..78368f5d2 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -13,18 +13,20 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| Field | Type | Required | Description | -| ---------- | ------ | -------- | ------------------------------------------------------------------ | -| enabled | bool | Yes | Whether to enable the Telegram channel | -| token | string | Yes | Telegram Bot API Token | -| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | -| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Telegram channel | +| token | string | Yes | Telegram Bot API Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | +| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | ## Setup @@ -53,3 +55,20 @@ Examples: /use git explain how to squash the last 3 commits ``` + +## Advanced Formatting + +You can set `use_markdown_v2: true` to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md index 8d2c935b4..e86d51d8e 100644 --- a/docs/channels/telegram/README.pt-br.md +++ b/docs/channels/telegram/README.pt-br.md @@ -13,18 +13,20 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| Campo | Tipo | Obrigatório | Descrição | -| ---------- | ------ | ----------- | -------------------------------------------------------------------------- | -| enabled | bool | Sim | Se o canal Telegram deve ser habilitado | -| token | string | Sim | Token da API de Bot do Telegram | -| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | -| proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) | +| Campo | Tipo | Obrigatório | Descrição | +| --------------- | ------ | ----------- | -------------------------------------------------------------------------- | +| enabled | bool | Sim | Se o canal Telegram deve ser habilitado | +| token | string | Sim | Token da API de Bot do Telegram | +| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | +| proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) | +| use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 | ## Configuração inicial @@ -33,3 +35,20 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica 3. Obtenha o Token da API HTTP 4. Preencha o Token no arquivo de configuração 5. (Opcional) Configure `allow_from` para restringir quais IDs de usuário podem interagir (os IDs podem ser obtidos via `@userinfobot`) + +## Formatação Avançada + +Você pode definir `use_markdown_v2: true` para habilitar opções de formatação aprimoradas. Isso permite que o bot utilize todos os recursos do Telegram MarkdownV2, incluindo estilos aninhados, spoilers e blocos de largura fixa personalizados. + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md index 858a9fc41..70ee1f51b 100644 --- a/docs/channels/telegram/README.vi.md +++ b/docs/channels/telegram/README.vi.md @@ -13,18 +13,20 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| Trường | Kiểu | Bắt buộc | Mô tả | -| ---------- | ------ | -------- | ------------------------------------------------------------------------ | -| enabled | bool | Có | Có bật kênh Telegram hay không | -| token | string | Có | Token API Bot Telegram | -| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | -| proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) | +| Trường | Kiểu | Bắt buộc | Mô tả | +| -------------- | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Có | Có bật kênh Telegram hay không | +| token | string | Có | Token API Bot Telegram | +| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | +| proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) | +| use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 | ## Hướng dẫn thiết lập @@ -33,3 +35,20 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d 3. Lấy Token API HTTP 4. Điền Token vào file cấu hình 5. (Tùy chọn) Cấu hình `allow_from` để giới hạn ID người dùng được phép tương tác (có thể lấy ID qua `@userinfobot`) + +## Định dạng nâng cao + +Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn định dạng nâng cao. Điều này cho phép bot sử dụng toàn bộ các tính năng của Telegram MarkdownV2, bao gồm các kiểu lồng nhau, spoiler và các khối chiều rộng cố định tùy chỉnh. + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} +``` diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index 1d9dcc46e..fc544cd86 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -13,18 +13,20 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 "enabled": true, "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "" + "proxy": "", + "use_markdown_v2": false } } } ``` -| 字段 | 类型 | 必填 | 描述 | -| ---------- | ------ | ---- | --------------------------------------------------------- | -| enabled | bool | 是 | 是否启用 Telegram 频道 | -| token | string | 是 | Telegram 机器人 API Token | -| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | -| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | --------------------------------------------------------- | +| enabled | bool | 是 | 是否启用 Telegram 频道 | +| token | string | 是 | Telegram 机器人 API Token | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | +| use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 | ## 设置流程 @@ -50,6 +52,23 @@ Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/st ```text /list skills /use git explain how to squash the last 3 commits -/use italiapersonalfinance -dammi le ultime news +/use git +explain how to squash the last 3 commits +``` + +## 高级格式化 + +您可以设置 `use_markdown_v2: true` 来启用增强的格式化选项。这允许机器人使用 Telegram MarkdownV2 的全部功能,包括嵌套样式、剧透和自定义等宽代码块。 + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "use_markdown_v2": true + } + } +} ``` From dd54601f2d115e83598d5a7c6f8f3f61ccc7f441 Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 31 Mar 2026 17:01:44 +0800 Subject: [PATCH 55/72] fix(web): hydrate cached Pico token for websocket proxy (#2222) Load the Pico token from config before validating websocket proxy requests when the launcher attaches to an existing gateway and the in-memory cache is still empty --- web/backend/api/gateway.go | 10 ++++++++ web/backend/api/pico.go | 1 + web/backend/api/pico_test.go | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index ce3a9ca1e..1e2520920 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -59,6 +59,16 @@ func refreshPicoTokensLocked(configPath string) { gateway.picoToken = cfg.Channels.Pico.Token.String() } +// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when +// the launcher has already discovered a running gateway via pidData, but has +// not yet refreshed the token into memory. +func ensurePicoTokenCachedLocked(configPath string) { + if gateway.picoToken != "" { + return + } + refreshPicoTokensLocked(configPath) +} + const ( protocolKey = "Sec-Websocket-Protocol" tokenPrefix = "token." diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 0e8cd07fc..c8ef47308 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -56,6 +56,7 @@ func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.Rev func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gateway.mu.Lock() + ensurePicoTokenCachedLocked(h.configPath) gatewayAvailable := gateway.pidData != nil gateway.mu.Unlock() diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index beff4d77f..ee5586746 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -377,6 +377,55 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { } } +func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + cfg.Channels.Pico.Enabled = true + cfg.Channels.Pico.SetToken("cached-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "" + + req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) + req.Header.Set(protocolKey, tokenPrefix+"cached-token") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "proxied" { + t.Fatalf("body = %q, want %q", body, "proxied") + } + if gateway.picoToken != "cached-token" { + t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, "cached-token") + } +} + func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper() From 93f391a6bf7a845c3d59dda2da032ee460118e6c Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Thu, 26 Mar 2026 00:31:22 +0700 Subject: [PATCH 56/72] fix(agent): include SystemParts in token estimation and add reasoning guards --- pkg/agent/context_budget.go | 9 +++++---- pkg/agent/context_budget_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/pkg/agent/context_budget.go b/pkg/agent/context_budget.go index c87695c7a..416ec4a5b 100644 --- a/pkg/agent/context_budget.go +++ b/pkg/agent/context_budget.go @@ -91,11 +91,12 @@ func findSafeBoundary(history []providers.Message, targetIndex int) int { // metadata, and Media items. Uses a heuristic of 2.5 characters per token. func estimateMessageTokens(msg providers.Message) int { chars := utf8.RuneCountInString(msg.Content) + chars += utf8.RuneCountInString(msg.ReasoningContent) - // ReasoningContent (extended thinking / chain-of-thought) can be - // substantial and is stored in session history via AddFullMessage. - if msg.ReasoningContent != "" { - chars += utf8.RuneCountInString(msg.ReasoningContent) + // SystemParts are structured system blocks that can be substantial + // when using instruction-heavy agents or KV-cache-aware adapters. + for _, part := range msg.SystemParts { + chars += utf8.RuneCountInString(part.Text) } for _, tc := range msg.ToolCalls { diff --git a/pkg/agent/context_budget_test.go b/pkg/agent/context_budget_test.go index 870f0fbe6..22cbdc0db 100644 --- a/pkg/agent/context_budget_test.go +++ b/pkg/agent/context_budget_test.go @@ -529,6 +529,26 @@ func TestEstimateMessageTokens_MediaItems(t *testing.T) { } } +func TestEstimateMessageTokens_SystemParts(t *testing.T) { + plain := providers.Message{Role: "system", Content: "instructions"} + withParts := providers.Message{ + Role: "system", + Content: "instructions", + SystemParts: []providers.ContentBlock{ + {Type: "text", Text: "some more system context"}, + {Type: "text", Text: "even more cached blocks"}, + }, + } + + plainTokens := estimateMessageTokens(plain) + partsTokens := estimateMessageTokens(withParts) + + if partsTokens <= plainTokens { + t.Errorf("system message with SystemParts (%d) should exceed plain message (%d)", + partsTokens, plainTokens) + } +} + // --- estimateToolDefsTokens tests --- func TestEstimateToolDefsTokens(t *testing.T) { From 1a44752dc56675e5ed04edfcd7ed65a6f097774b Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Tue, 31 Mar 2026 17:09:01 +0700 Subject: [PATCH 57/72] fix(agent): prevent double-counting system message tokens in estimator Treat SystemParts as an alternative representation of message Content rather than an additive one. This prevents systematic overestimation of system message tokens which could trigger premature context pruning or summarization. - Picks the maximum of Content vs. SystemParts to stay conservative. - Adds a per-part overhead (20 chars) to account for JSON metadata. - Streamlines the ReasoningContent counting logic. Fixes a deficiency where structured blocks for cache-aware adapters caused overestimated budgets or hidden overflows. --- pkg/agent/context_budget.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/pkg/agent/context_budget.go b/pkg/agent/context_budget.go index 416ec4a5b..3398d7863 100644 --- a/pkg/agent/context_budget.go +++ b/pkg/agent/context_budget.go @@ -90,15 +90,29 @@ func findSafeBoundary(history []providers.Message, targetIndex int) int { // including Content, ReasoningContent, ToolCalls arguments, ToolCallID // metadata, and Media items. Uses a heuristic of 2.5 characters per token. func estimateMessageTokens(msg providers.Message) int { - chars := utf8.RuneCountInString(msg.Content) - chars += utf8.RuneCountInString(msg.ReasoningContent) + contentChars := utf8.RuneCountInString(msg.Content) - // SystemParts are structured system blocks that can be substantial - // when using instruction-heavy agents or KV-cache-aware adapters. - for _, part := range msg.SystemParts { - chars += utf8.RuneCountInString(part.Text) + // SystemParts are structured system blocks used for cache-aware adapters. + // They carry the same content as Content, but in multiple blocks. + // We estimate them as an alternative representation, not additive. + systemPartsChars := 0 + if len(msg.SystemParts) > 0 { + for _, part := range msg.SystemParts { + systemPartsChars += utf8.RuneCountInString(part.Text) + } + // Per-part overhead for JSON structure (type, text, cache_control). + const perPartOverhead = 20 + systemPartsChars += len(msg.SystemParts) * perPartOverhead } + // Use the larger of the two representations to stay conservative. + chars := contentChars + if systemPartsChars > chars { + chars = systemPartsChars + } + + chars += utf8.RuneCountInString(msg.ReasoningContent) + for _, tc := range msg.ToolCalls { chars += len(tc.ID) + len(tc.Type) if tc.Function != nil { From 2bf842e46070feb37915254f5a9a767126b4cbeb Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 31 Mar 2026 20:32:42 +0800 Subject: [PATCH 58/72] feat(web): add service log level controls (#2227) - centralize gateway log level resolution and normalization - propagate debug flags to spawned launcher and gateway processes - add a log level selector to the logs page - cover the new behavior with backend and config tests --- pkg/config/config.go | 7 -- pkg/config/config_test.go | 32 ++++++ pkg/config/defaults.go | 2 +- pkg/config/gateway.go | 72 +++++++++++++ pkg/gateway/gateway.go | 17 ++- web/backend/api/config.go | 15 ++- web/backend/api/config_test.go | 92 ++++++++++++++++ web/backend/api/gateway.go | 10 +- web/backend/api/gateway_test.go | 13 +++ web/backend/api/router.go | 5 + web/backend/api/startup.go | 3 + web/backend/api/startup_test.go | 23 ++++ web/backend/main.go | 62 ++++++++--- web/backend/main_test.go | 31 ++++++ .../src/components/logs/log-level-select.tsx | 102 ++++++++++++++++++ .../src/components/logs/logs-page.tsx | 23 ++-- web/frontend/src/i18n/locales/en.json | 1 + web/frontend/src/i18n/locales/zh.json | 1 + 18 files changed, 471 insertions(+), 40 deletions(-) create mode 100644 pkg/config/gateway.go create mode 100644 web/backend/main_test.go create mode 100644 web/frontend/src/components/logs/log-level-select.tsx diff --git a/pkg/config/config.go b/pkg/config/config.go index 87cb31f9e..397cd4ab8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -636,13 +636,6 @@ func (c *ModelConfig) SetAPIKey(value string) { } } -type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` - HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` - LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` -} - type ToolDiscoveryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 6734257f4..278dfa43a 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1418,6 +1418,38 @@ func TestConfigLogLevelEmpty(t *testing.T) { } } +func TestResolveGatewayLogLevel(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":1,"gateway":{"log_level":"debug"}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + if got := ResolveGatewayLogLevel(cfgPath); got != "debug" { + t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug") + } +} + +func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"version":1,"gateway":{"log_level":"debug"}}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_LOG_LEVEL", "warning") + if got := ResolveGatewayLogLevel(cfgPath); got != "warn" { + t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn") + } + + t.Setenv("PICOCLAW_LOG_LEVEL", "garbage") + if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel { + t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel) + } +} + func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index bded97fcd..c3845e3e2 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -347,7 +347,7 @@ func DefaultConfig() *Config { Host: "127.0.0.1", Port: 18790, HotReload: false, - LogLevel: "warn", + LogLevel: DefaultGatewayLogLevel, }, Tools: ToolsConfig{ FilterSensitiveData: true, diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go new file mode 100644 index 000000000..e9f4085d3 --- /dev/null +++ b/pkg/config/gateway.go @@ -0,0 +1,72 @@ +package config + +import ( + "encoding/json" + "os" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +const DefaultGatewayLogLevel = "warn" + +type GatewayConfig struct { + Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` + Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` + LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` +} + +func canonicalGatewayLogLevel(level logger.LogLevel) string { + switch level { + case logger.DEBUG: + return "debug" + case logger.INFO: + return "info" + case logger.WARN: + return "warn" + case logger.ERROR: + return "error" + case logger.FATAL: + return "fatal" + default: + return DefaultGatewayLogLevel + } +} + +func normalizeGatewayLogLevel(logLevel string) string { + if level, ok := logger.ParseLevel(logLevel); ok { + return canonicalGatewayLogLevel(level) + } + return DefaultGatewayLogLevel +} + +// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config. +// Invalid or empty values fall back to the package default. +func EffectiveGatewayLogLevel(cfg *Config) string { + if cfg == nil { + return DefaultGatewayLogLevel + } + return normalizeGatewayLogLevel(cfg.Gateway.LogLevel) +} + +// ResolveGatewayLogLevel reads the configured gateway log level without triggering +// the full config loader, so startup code can apply logging before config load logs run. +// The PICOCLAW_LOG_LEVEL environment variable overrides the file value. +func ResolveGatewayLogLevel(path string) string { + cfg := struct { + Gateway GatewayConfig `json:"gateway"` + }{ + Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel}, + } + + data, err := os.ReadFile(path) + if err == nil { + _ = json.Unmarshal(data, &cfg) + } + + if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" { + cfg.Gateway.LogLevel = envLevel + } + + return normalizeGatewayLogLevel(cfg.Gateway.LogLevel) +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index a47bf2ac6..64aed5e8c 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -98,6 +98,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error } defer logger.DisableFileLogging() + if debug { + logger.SetLevel(logger.DEBUG) + } else { + logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath)) + } + cfg, err := config.LoadConfig(configPath) if err != nil { logger.Fatalf("error loading config: %v", err) @@ -109,11 +115,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error // Debug mode permanently overrides the config log level to DEBUG. if debug { - logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") } else { - logger.SetLevelFromString(cfg.Gateway.LogLevel) - logger.Infof("Log level set to %q", cfg.Gateway.LogLevel) + effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg) + logger.SetLevelFromString(effectiveLogLevel) + logger.Infof("Log level set to %q", effectiveLogLevel) } // Enforce singleton: write PID file with generated token. @@ -476,8 +482,9 @@ func handleConfigReload( // Debug mode permanently overrides the config log level to DEBUG. if !debug { // Update log level last so that reload-related info/warn logs above are not suppressed. - logger.SetLevelFromString(newCfg.Gateway.LogLevel) - logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel) + effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg) + logger.SetLevelFromString(effectiveLogLevel) + logger.Infof("Log level changing from current to %q", effectiveLogLevel) } return nil diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 7c8e21308..5490b4e18 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -20,6 +20,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns) } +func (h *Handler) applyRuntimeLogLevel() { + if h.debug { + logger.SetLevel(logger.DEBUG) + return + } + logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath)) +} + // handleGetConfig returns the complete system configuration. // // GET /api/config @@ -80,8 +88,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } - logger.Infof("configuration updated successfully") - if err := config.SaveConfig(h.configPath, &cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) return @@ -89,6 +95,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { // Refresh cached pico token in case user changed it. refreshPicoToken(&cfg) + h.applyRuntimeLogLevel() + logger.Infof("configuration updated successfully") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) @@ -133,7 +141,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - existing, err := json.Marshal(cfg) if err != nil { http.Error(w, "Failed to serialize current config", http.StatusInternalServerError) @@ -187,6 +194,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // Refresh cached pico token in case user changed it. refreshPicoToken(&newCfg) + h.applyRuntimeLogLevel() + logger.Infof("configuration updated successfully") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index d3e25a7f9..a90145f3c 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -9,8 +9,38 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) +func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) { + t.Helper() + + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + initialLevel := logger.GetLevel() + logger.SetLevel(logger.INFO) + t.Cleanup(func() { + logger.SetLevel(initialLevel) + }) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String()) + } + if got := logger.GetLevel(); got != want { + t.Fatalf("logger.GetLevel() = %v, want %v", got, want) + } +} + func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -251,6 +281,68 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { } } +func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) { + assertGatewayLogLevelApplied(t, http.MethodPut, `{ + "version": 1, + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model_name": "custom-default" + } + }, + "gateway": { + "log_level": "error" + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_keys": ["sk-default"] + } + ] + }`, logger.ERROR) +} + +func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) { + assertGatewayLogLevelApplied(t, http.MethodPatch, `{ + "gateway": { + "log_level": "debug" + } + }`, logger.DEBUG) +} + +func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + initialLevel := logger.GetLevel() + logger.SetLevel(logger.INFO) + t.Cleanup(func() { + logger.SetLevel(initialLevel) + }) + + h := NewHandler(configPath) + h.SetDebug(true) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "gateway": { + "log_level": "error" + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := logger.GetLevel(); got != logger.DEBUG { + t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG) + } +} + func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 1e2520920..6f5f5dd5d 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -69,6 +69,14 @@ func ensurePicoTokenCachedLocked(configPath string) { refreshPicoTokensLocked(configPath) } +func (h *Handler) gatewayCommandArgs() []string { + args := []string{"gateway", "-E"} + if h.debug { + args = append(args, "-d") + } + return args +} + const ( protocolKey = "Sec-Websocket-Protocol" tokenPrefix = "token." @@ -531,7 +539,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int execPath := utils.FindPicoclawBinary() logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) - cmd = exec.Command(execPath, "gateway", "-E") + cmd = exec.Command(execPath, h.gatewayCommandArgs()...) cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 0ef027490..fc8ee13f3 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -77,6 +77,8 @@ func resetGatewayTestState(t *testing.T) { gateway.mu.Lock() gateway.cmd = nil + gateway.pidData = nil + gateway.owned = false gateway.bootDefaultModel = "" gateway.bootConfigSignature = "" setGatewayRuntimeStatusLocked("stopped") @@ -166,6 +168,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { } } +func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) + h.SetDebug(true) + + args := h.gatewayCommandArgs() + want := []string{"gateway", "-E", "-d"} + if strings.Join(args, " ") != strings.Join(want, " ") { + t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want) + } +} + func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/backend/api/router.go b/web/backend/api/router.go index af490d8b5..3823fe08c 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -14,6 +14,7 @@ type Handler struct { serverPublic bool serverPublicExplicit bool serverCIDRs []string + debug bool oauthMu sync.Mutex oauthFlows map[string]*oauthFlow oauthState map[string]string @@ -43,6 +44,10 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverCIDRs = append([]string(nil), allowedCIDRs...) } +func (h *Handler) SetDebug(debug bool) { + h.debug = debug +} + // RegisterRoutes binds all API endpoint handlers to the ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Config CRUD diff --git a/web/backend/api/startup.go b/web/backend/api/startup.go index 1c685bc90..8a3b8e8ff 100644 --- a/web/backend/api/startup.go +++ b/web/backend/api/startup.go @@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) { } args := []string{"-no-browser"} + if h.debug { + args = append(args, "-d") + } if h.configPath != "" { args = append(args, h.configPath) } diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go index cfa9b4c53..c224d36e2 100644 --- a/web/backend/api/startup_test.go +++ b/web/backend/api/startup_test.go @@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) { } } +func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetDebug(true) + + _, args, err := h.resolveLaunchCommand() + if err != nil { + t.Fatalf("resolveLaunchCommand() error = %v", err) + } + if len(args) != 3 { + t.Fatalf("args len = %d, want 3 (got %v)", len(args), args) + } + if args[0] != "-no-browser" { + t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser") + } + if args[1] != "-d" { + t.Fatalf("args[1] = %q, want %q", args[1], "-d") + } + if args[2] != configPath { + t.Fatalf("args[2] = %q, want %q", args[2], configPath) + } +} + func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) { plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"}) if !strings.Contains(plist, "RunAtLoad") { diff --git a/web/backend/main.go b/web/backend/main.go index c58e97361..218e3bfce 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -55,6 +55,10 @@ var ( noBrowser *bool ) +func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { + return !enableConsole || debug +} + func main() { port := flag.String("port", "18800", "Port to listen on") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") @@ -62,21 +66,30 @@ func main() { lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale") console := flag.Bool("console", false, "Console mode, no GUI") + var debug bool + flag.BoolVar(&debug, "d", false, "Enable debug logging") + flag.BoolVar(&debug, "debug", false, "Enable debug logging") + flag.Usage = func() { - fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName) + fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName) fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Arguments:\n") fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") fmt.Fprintf(os.Stderr, "Options:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n") + fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Specify a config file\n") fmt.Fprintf( os.Stderr, - " %s -public ./config.json Allow access from other devices on the network\n", + " %s -public ./config.json\n", os.Args[0], ) + fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n") + fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n") } flag.Parse() @@ -90,12 +103,13 @@ func main() { } defer panicFunc() - // By default, detect terminal to decide console log behavior - // If -console-logs flag is explicitly set, it overrides the detection enableConsole := *console - if !enableConsole { - // Disable console logging by setting level to Fatal (no output) - logger.SetConsoleLevel(logger.FATAL) + fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug) + if fileLoggingEnabled { + // GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too. + if !debug { + logger.DisableConsole() + } f := filepath.Join(picoHome, logPath, logFile) if err = logger.EnableFileLogging(f); err != nil { @@ -103,9 +117,9 @@ func main() { } defer logger.DisableFileLogging() } - - logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion)) - logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome)) + if debug { + logger.SetLevel(logger.DEBUG) + } // Set language from command line or auto-detect if *lang != "" { @@ -126,6 +140,25 @@ func main() { if err != nil { logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err) } + if !debug { + logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath)) + } + + logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion)) + logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome)) + if debug { + logger.InfoC("web", "Debug mode enabled") + logger.DebugC( + "web", + fmt.Sprintf( + "Launcher flags: console=%t public=%t no_browser=%t config=%s", + enableConsole, + *public, + *noBrowser, + absPath, + ), + ) + } var explicitPort bool var explicitPublic bool @@ -181,7 +214,7 @@ func main() { mux := http.NewServeMux() tokenLogFileAbs := "" - if !enableConsole { + if fileLoggingEnabled { tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile) } api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ @@ -197,6 +230,7 @@ func main() { // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) + apiHandler.SetDebug(debug) if _, err = apiHandler.EnsurePicoChannel(""); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } @@ -226,7 +260,7 @@ func main() { ) // Print startup banner and token (console mode only). - if enableConsole { + if enableConsole || debug { fmt.Print(utils.Banner) fmt.Println() fmt.Println(" Open the following URL in your browser:") diff --git a/web/backend/main_test.go b/web/backend/main_test.go new file mode 100644 index 000000000..c24a53704 --- /dev/null +++ b/web/backend/main_test.go @@ -0,0 +1,31 @@ +package main + +import "testing" + +func TestShouldEnableLauncherFileLogging(t *testing.T) { + tests := []struct { + name string + enableConsole bool + debug bool + want bool + }{ + {name: "gui mode", enableConsole: false, debug: false, want: true}, + {name: "console mode", enableConsole: true, debug: false, want: false}, + {name: "debug gui mode", enableConsole: false, debug: true, want: true}, + {name: "debug console mode", enableConsole: true, debug: true, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldEnableLauncherFileLogging(tt.enableConsole, tt.debug); got != tt.want { + t.Fatalf( + "shouldEnableLauncherFileLogging(%t, %t) = %t, want %t", + tt.enableConsole, + tt.debug, + got, + tt.want, + ) + } + }) + } +} diff --git a/web/frontend/src/components/logs/log-level-select.tsx b/web/frontend/src/components/logs/log-level-select.tsx new file mode 100644 index 000000000..a8a273b32 --- /dev/null +++ b/web/frontend/src/components/logs/log-level-select.tsx @@ -0,0 +1,102 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { type AppConfig, getAppConfig, patchAppConfig } from "@/api/channels" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { refreshGatewayState } from "@/store/gateway" + +const LOG_LEVEL_OPTIONS = ["debug", "info", "warn", "error", "fatal"] as const +type GatewayLogLevel = (typeof LOG_LEVEL_OPTIONS)[number] + +const LOG_LEVEL_LABELS: Record = { + debug: "Debug", + info: "Info", + warn: "Warn", + error: "Error", + fatal: "Fatal", +} + +function getGatewayLogLevel(config: AppConfig | undefined): GatewayLogLevel { + const gateway = config?.gateway + if (typeof gateway === "object" && gateway !== null) { + const logLevel = (gateway as Record).log_level + if ( + typeof logLevel === "string" && + LOG_LEVEL_OPTIONS.includes(logLevel as GatewayLogLevel) + ) { + return logLevel as GatewayLogLevel + } + } + return "warn" +} + +export function LogLevelSelect() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [logLevel, setLogLevel] = useState("warn") + const [savingLogLevel, setSavingLogLevel] = useState(false) + + const { data: configData } = useQuery({ + queryKey: ["config"], + queryFn: getAppConfig, + }) + + useEffect(() => { + setLogLevel(getGatewayLogLevel(configData)) + }, [configData]) + + const handleLogLevelChange = async (nextValue: string) => { + const nextLevel = nextValue as GatewayLogLevel + const previousLevel = logLevel + setLogLevel(nextLevel) + setSavingLogLevel(true) + + try { + await patchAppConfig({ + gateway: { + log_level: nextLevel, + }, + }) + await queryClient.invalidateQueries({ queryKey: ["config"] }) + await refreshGatewayState({ force: true }) + } catch (error) { + setLogLevel(previousLevel) + toast.error( + error instanceof Error + ? error.message + : t("pages.logs.log_level_error"), + ) + } finally { + setSavingLogLevel(false) + } + } + + return ( +
+ +
+ ) +} diff --git a/web/frontend/src/components/logs/logs-page.tsx b/web/frontend/src/components/logs/logs-page.tsx index a4c458fa2..853da223a 100644 --- a/web/frontend/src/components/logs/logs-page.tsx +++ b/web/frontend/src/components/logs/logs-page.tsx @@ -1,6 +1,7 @@ import { IconTrash } from "@tabler/icons-react" import { useTranslation } from "react-i18next" +import { LogLevelSelect } from "@/components/logs/log-level-select" import { LogsPanel } from "@/components/logs/logs-panel" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" @@ -17,15 +18,19 @@ export function LogsPage() { - - {t("pages.logs.clear")} - + <> + + + + } /> diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 69e256758..c512eafbe 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -547,6 +547,7 @@ "unsaved_changes": "You have unsaved changes." }, "logs": { + "log_level_error": "Failed to update log level.", "clear": "Clear logs", "empty": "Waiting for logs..." } diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e7aca1918..54d3fe1b3 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -547,6 +547,7 @@ "unsaved_changes": "您有未保存的更改。" }, "logs": { + "log_level_error": "更新日志等级失败。", "clear": "清空日志", "empty": "等待日志中..." } From 3b3f95c44cec309be99df08f973b26e33348bce4 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:52:04 +0800 Subject: [PATCH 59/72] feat(web): refine model availability states and preserve API key preview placeholder (#2226) * feat(web): clarify model availability and status display - Rename model availability field from configured to available across backend API and frontend usage - Keep status as reason classification (configured/unconfigured/unreachable) and show unreachable in UI - Preserve API key preview even when local service is unreachable - Update backend tests to assert both availability and status semantics * fix(web): clarify unreachable model status and wording - Show unreachable status in model cards instead of API key preview when service is down - Keep API key placeholder preview in model settings whenever an API key is already saved - Rename model status wording from configured to available across backend, frontend, and i18n - Update backend model status tests to match renamed status semantics * style(web): standardize formatting in handleListModels function * refactor(web): enforce status field as required to follow backend behavior --- web/backend/api/model_status.go | 24 +++- web/backend/api/models.go | 16 +-- web/backend/api/models_test.go | 110 +++++++++++++++--- web/frontend/src/api/models.ts | 3 +- .../src/components/chat/chat-empty-state.tsx | 6 +- .../src/components/chat/chat-page.tsx | 6 +- .../components/models/edit-model-sheet.tsx | 7 +- .../src/components/models/model-card.tsx | 20 ++-- .../src/components/models/models-page.tsx | 16 +-- web/frontend/src/hooks/use-chat-models.ts | 12 +- web/frontend/src/i18n/locales/en.json | 5 +- web/frontend/src/i18n/locales/zh.json | 5 +- 12 files changed, 161 insertions(+), 69 deletions(-) diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 2269147df..160c4d257 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -15,6 +15,17 @@ import ( const modelProbeTimeout = 800 * time.Millisecond +const ( + modelStatusAvailable = "available" + modelStatusUnconfigured = "unconfigured" + modelStatusUnreachable = "unreachable" +) + +type modelConfigurationSummary struct { + Available bool + Status string +} + var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel @@ -43,16 +54,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } -// isModelConfigured reports whether a model is currently available to use. -// Local models must be reachable; remote/API-key models only need saved config. -func isModelConfigured(m *config.ModelConfig) bool { +func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { if !hasModelConfiguration(m) { - return false + return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} } if requiresRuntimeProbe(m) { - return probeLocalModelAvailability(m) + if probeLocalModelAvailability(m) { + return modelConfigurationSummary{Available: true, Status: modelStatusAvailable} + } + return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable} } - return true + return modelConfigurationSummary{Available: true, Status: modelStatusAvailable} } func requiresRuntimeProbe(m *config.ModelConfig) bool { diff --git a/web/backend/api/models.go b/web/backend/api/models.go index fd3cd85b7..e6749b56e 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -40,10 +40,11 @@ type modelResponse struct { ThinkingLevel string `json:"thinking_level,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` // Meta - Enabled bool `json:"enabled"` - Configured bool `json:"configured"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` } // handleListModels returns all model_list entries with masked API keys. @@ -57,14 +58,14 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { } defaultModel := cfg.Agents.Defaults.GetModelName() - configured := make([]bool, len(cfg.ModelList)) + modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) var wg sync.WaitGroup wg.Add(len(cfg.ModelList)) for i, m := range cfg.ModelList { go func(i int, m *config.ModelConfig) { defer wg.Done() - configured[i] = isModelConfigured(m) + modelStatuses[i] = modelConfigurationStatus(m) }(i, m) } wg.Wait() @@ -87,7 +88,8 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, Enabled: m.Enabled, - Configured: configured[i], + Available: modelStatuses[i].Available, + Status: modelStatuses[i].Status, IsDefault: m.ModelName == defaultModel, IsVirtual: m.IsVirtual(), }) diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 97f153a80..e78de1606 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -27,7 +27,7 @@ func resetModelProbeHooks(t *testing.T) { }) } -func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { +func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) @@ -113,25 +113,42 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes t.Fatalf("Unmarshal() error = %v", err) } - got := make(map[string]bool, len(resp.Models)) + gotAvailable := make(map[string]bool, len(resp.Models)) + gotStatus := make(map[string]string, len(resp.Models)) for _, model := range resp.Models { - got[model.ModelName] = model.Configured + gotAvailable[model.ModelName] = model.Available + gotStatus[model.ModelName] = model.Status } - if got["openai-oauth"] { - t.Fatalf("openai oauth model configured = true, want false without stored credential") + if gotAvailable["openai-oauth"] { + t.Fatalf("openai oauth model available = true, want false without stored credential") } - if !got["vllm-local"] { - t.Fatalf("vllm local model configured = false, want true when local probe succeeds") + if !gotAvailable["vllm-local"] { + t.Fatalf("vllm local model available = false, want true when local probe succeeds") } - if !got["ollama-default"] { - t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") + if !gotAvailable["ollama-default"] { + t.Fatalf("ollama default model available = false, want true when default local probe succeeds") } - if !got["vllm-remote"] { - t.Fatalf("remote vllm model configured = false, want true with api_key") + if !gotAvailable["vllm-remote"] { + t.Fatalf("remote vllm model available = false, want true with api_key") } - if !got["copilot-gpt-5.4"] { - t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") + if !gotAvailable["copilot-gpt-5.4"] { + t.Fatalf("copilot model available = false, want true when local bridge probe succeeds") + } + if gotStatus["openai-oauth"] != modelStatusUnconfigured { + t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured) + } + if gotStatus["vllm-local"] != modelStatusAvailable { + t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable) + } + if gotStatus["ollama-default"] != modelStatusAvailable { + t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable) + } + if gotStatus["vllm-remote"] != modelStatusAvailable { + t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable) + } + if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable { + t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable) } if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) @@ -144,7 +161,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes } } -func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { +func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() resetOAuthHooks(t) @@ -193,8 +210,8 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } - if !resp.Models[0].Configured { - t.Fatalf("oauth model configured = false, want true with stored credential") + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") } } @@ -306,14 +323,71 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } - if !resp.Models[0].Configured { - t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") + if !resp.Models[0].Available { + t.Fatal("wildcard-bound local model available = false, want true after probe host normalization") } if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") } } +func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local-down", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + APIKeys: config.SimpleSecureStrings("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) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + + if resp.Models[0].Available { + t.Fatal("unreachable local model available = true, want false") + } + if resp.Models[0].Status != modelStatusUnreachable { + t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable) + } + if resp.Models[0].APIKey == "" { + t.Fatal("masked API key preview should still be returned when API key is configured") + } +} + func TestHandleAddModel_PersistsAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index d75b3ec3c..eb8d287dd 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -20,7 +20,8 @@ export interface ModelInfo { thinking_level?: string extra_body?: Record // Meta - configured: boolean + available: boolean + status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean } diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 0574c44d1..7e1abca17 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -10,19 +10,19 @@ import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" interface ChatEmptyStateProps { - hasConfiguredModels: boolean + hasAvailableModels: boolean defaultModelName: string isConnected: boolean } export function ChatEmptyState({ - hasConfiguredModels, + hasAvailableModels, defaultModelName, isConnected, }: ChatEmptyStateProps) { const { t } = useTranslation() - if (!hasConfiguredModels) { + if (!hasAvailableModels) { return (
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index ebcde8981..ae705ff1b 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -39,7 +39,7 @@ export function ChatPage() { const { defaultModelName, - hasConfiguredModels, + hasAvailableModels, apiKeyModels, oauthModels, localModels, @@ -94,7 +94,7 @@ export function ChatPage() { hasScrolled ? "shadow-sm" : "shadow-none" }`} titleExtra={ - hasConfiguredModels && ( + hasAvailableModels && ( {messages.length === 0 && !isTyping && ( diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index d1cba6719..52e2d8d9d 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -133,9 +133,10 @@ export function EditModelSheet({ } const isOAuth = model?.auth_method === "oauth" - const apiKeyPlaceholder = model?.configured + const hasSavedAPIKey = Boolean(model?.api_key) + const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( - model.api_key, + model?.api_key ?? "", t("models.field.apiKeyPlaceholderSet"), ) : t("models.field.apiKeyPlaceholder") @@ -161,7 +162,7 @@ export function EditModelSheet({ {model.model_name} @@ -127,14 +127,14 @@ export function ModelCard({ OAuth - ) : model.configured && model.api_key ? ( + ) : status === "available" && model.api_key ? ( {model.api_key} ) : ( - {t("models.status.unconfigured")} + {statusLabel} )}
diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index a6747c5e0..c08b3bdd6 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -40,7 +40,7 @@ interface ProviderGroup { label: string models: ModelInfo[] hasDefault: boolean - configuredCount: number + availableCount: number } export function ModelsPage() { @@ -62,8 +62,8 @@ export function ModelsPage() { const sorted = [...data.models].sort((a, b) => { if (a.is_default && !b.is_default) return -1 if (!a.is_default && b.is_default) return 1 - if (a.configured && !b.configured) return -1 - if (!a.configured && b.configured) return 1 + if (a.available && !b.available) return -1 + if (!a.available && b.available) return 1 return a.model_name.localeCompare(b.model_name) }) setModels(sorted) @@ -107,23 +107,23 @@ export function ModelsPage() { const providerGroups: ProviderGroup[] = Object.entries(grouped) .map(([key, group]) => { - const configuredCount = group.models.filter( - (model) => model.configured, + const availableCount = group.models.filter( + (model) => model.available, ).length return { key, label: group.label, models: group.models, hasDefault: group.models.some((model) => model.is_default), - configuredCount, + availableCount, } }) .sort((a, b) => { if (a.hasDefault && !b.hasDefault) return -1 if (!a.hasDefault && b.hasDefault) return 1 - if (a.configuredCount !== b.configuredCount) { - return b.configuredCount - a.configuredCount + if (a.availableCount !== b.availableCount) { + return b.availableCount - a.availableCount } const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 9afa882db..17cfba00f 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -65,32 +65,32 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { [defaultModelName], ) - const hasConfiguredModels = useMemo( - () => modelList.some((m) => m.configured), + const hasAvailableModels = useMemo( + () => modelList.some((m) => m.available), [modelList], ) const oauthModels = useMemo( - () => modelList.filter((m) => m.configured && m.auth_method === "oauth"), + () => modelList.filter((m) => m.available && m.auth_method === "oauth"), [modelList], ) const localModels = useMemo( - () => modelList.filter((m) => m.configured && isLocalModel(m)), + () => modelList.filter((m) => m.available && isLocalModel(m)), [modelList], ) const apiKeyModels = useMemo( () => modelList.filter( - (m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m), + (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m), ), [modelList], ) return { defaultModelName, - hasConfiguredModels, + hasAvailableModels, apiKeyModels, oauthModels, localModels, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index c512eafbe..d79e90ef7 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -170,8 +170,9 @@ "noDefaultHintPrefix": "No default model set yet. Click", "noDefaultHintSuffix": "to set one.", "status": { - "configured": "Configured", - "unconfigured": "Not configured" + "available": "Available", + "unconfigured": "Not configured", + "unreachable": "Service unreachable" }, "badge": { "default": "Default", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 54d3fe1b3..e5aa71a44 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -170,8 +170,9 @@ "noDefaultHintPrefix": "尚未设置默认模型,点击", "noDefaultHintSuffix": "设为默认。", "status": { - "configured": "已配置", - "unconfigured": "未配置" + "available": "可用", + "unconfigured": "未配置", + "unreachable": "服务不可达" }, "badge": { "default": "默认", From b90a6d12ea5b135e55b583464308caf4d0910c34 Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Wed, 1 Apr 2026 03:13:34 +0700 Subject: [PATCH 60/72] =?UTF-8?q?=EF=BB=BFfix(telegram):=20refine=20duplic?= =?UTF-8?q?ate-message=20protection=20with=20narrow=20error=20classificati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer concerns regarding silent message loss by narrowing the error swallowing logic in EditMessage: - Excludes context.DeadlineExceeded and context.Canceled from being swallowed, ensuring local timeouts before transmission still trigger a fallback send. - Adds an explicit check for the 'message is not modified' error to safely identify edits that have already landed on Telegram's servers. - Narrowly targets confirmed post-connect dropouts (e.g., connection reset) instead of broad network-ish string matching. - Fixes the missing isPostConnectError definition and required errors import. --- pkg/channels/telegram/telegram.go | 59 +++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 831eb43cc..c1097bf04 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "errors" "fmt" "io" "net/http" @@ -377,8 +378,38 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag } _, err = c.bot.EditMessageText(ctx, editMsg) if err != nil { - logParseFailed(err, useMarkdownV2) - _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + // If it failed because it was already modified (likely from a previous + // attempt that timed out on our end but landed on Telegram), we treat + // it as success to prevent the Manager from sending a duplicate message. + if strings.Contains(err.Error(), "message is not modified") { + return nil + } + + // Only fallback to plain text if the error looks like a parsing failure (Bad Request). + // Network errors or timeouts should NOT trigger a retry with different content. + if strings.Contains(err.Error(), "Bad Request") { + logParseFailed(err, useMarkdownV2) + _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + } + } + + if err != nil { + if strings.Contains(err.Error(), "message is not modified") { + return nil + } + + if isPostConnectError(err) { + logger.WarnCF( + "telegram", + "EditMessage likely landed but result is unknown; swallowing error to prevent duplicate", + map[string]any{ + "chat_id": chatID, + "mid": mid, + "error": err.Error(), + }, + ) + return nil // Swallow to prevent Manager fallback to a new SendMessage + } } return err @@ -1133,3 +1164,27 @@ func cryptoRandInt() int { _, _ = rand.Read(b[:]) return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero } + +// isPostConnectError identifies network errors that likely occurred after +// the request was transmitted to Telegram (e.g. dropped connection while +// waiting for response). Swallowing these for edits prevents duplicate +// fallbacks, at the small risk of leaving a stale placeholder if the +// edit never actually reached the server. +func isPostConnectError(err error) bool { + if err == nil { + return false + } + + // Context errors (timeout/canceled) are too broad; they can be triggered + // locally before any data is sent. Never swallow them. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } + + msg := strings.ToLower(err.Error()) + // Narrowly target connection dropouts where the request likely landed. + return strings.Contains(msg, "connection reset by peer") || + strings.Contains(msg, "unexpected eof") || + strings.Contains(msg, "connection closed by foreign host") || + strings.Contains(msg, "broken pipe") +} From ff90a65814a42b197e96f5be5a2ae59e29573202 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:50:15 +0800 Subject: [PATCH 61/72] docs: update support android news (#2228) --- README.fr.md | 6 ++++-- README.id.md | 6 ++++-- README.it.md | 6 ++++-- README.ja.md | 6 ++++-- README.md | 6 ++++-- README.my.md | 6 ++++-- README.pt-br.md | 6 ++++-- README.vi.md | 6 ++++-- README.zh.md | 6 ++++-- 9 files changed, 36 insertions(+), 18 deletions(-) diff --git a/README.fr.md b/README.fr.md index 8a035f9b3..a0cb84ce3 100644 --- a/README.fr.md +++ b/README.fr.md @@ -57,6 +57,8 @@ ## 📢 Actualités +2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** ! 2026-03-17 🚀 **v0.2.3 publiée !** Interface system tray (Windows & Linux), requête de statut des sous-agents (`spawn_status`), rechargement à chaud expérimental du Gateway, sécurisation Cron, et 2 correctifs de sécurité. PicoClaw a atteint **25K Stars** ! @@ -321,9 +323,9 @@ Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configur PicoClaw on Termux -**Option 2 : Installation APK (bientôt disponible)** +**Option 2 : Installation APK** -Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute ! +Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
Terminal Launcher (pour les environnements à ressources limitées) diff --git a/README.id.md b/README.id.md index 3fe4c1276..bba010dec 100644 --- a/README.id.md +++ b/README.id.md @@ -56,6 +56,8 @@ ## 📢 Berita +2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**! 2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**! @@ -318,9 +320,9 @@ Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi PicoClaw on Termux -**Opsi 2: Instal APK (segera hadir)** +**Opsi 2: Instal APK** -APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus! +Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) diff --git a/README.it.md b/README.it.md index 8748aea9c..50f08ad8b 100644 --- a/README.it.md +++ b/README.it.md @@ -56,6 +56,8 @@ ## 📢 Novità +2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**! 2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**! @@ -318,9 +320,9 @@ Poi segui la sezione Terminal Launcher qui sotto per completare la configurazion PicoClaw on Termux -**Opzione 2: APK Install (prossimamente)** +**Opzione 2: Installazione APK** -Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato! +Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
Terminal Launcher (per ambienti con risorse limitate) diff --git a/README.ja.md b/README.ja.md index 3772ff532..7171a87b9 100644 --- a/README.ja.md +++ b/README.ja.md @@ -56,6 +56,8 @@ ## 📢 ニュース +2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作!APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード + 2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新(SubTurn、Hooks、Steering、EventBus)、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング)、新プロバイダー(AWS Bedrock、Azure、Xiaomi MiMo)、35 件のバグ修正。PicoClaw **26K ⭐** 達成! 2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成! @@ -318,9 +320,9 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル PicoClaw on Termux -**オプション 2: APK インストール(近日公開)** +**オプション 2: APK インストール** -内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに! +[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
Terminal Launcher(リソース制約環境向け) diff --git a/README.md b/README.md index 947fed9e2..db38e644f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ ## 📢 News +2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**! 2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status query (`spawn_status`), experimental Gateway hot-reload, Cron security gating, and 2 security fixes. PicoClaw has reached **25K Stars**! @@ -318,9 +320,9 @@ Then follow the Terminal Launcher section below to complete configuration. PicoClaw on Termux -**Option 2: APK Install (coming soon)** +**Option 2: APK Install** -A standalone Android APK with built-in WebUI is in development. Stay tuned! +Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
Terminal Launcher (for resource-constrained environments) diff --git a/README.my.md b/README.my.md index c07cdd005..095d4b66a 100644 --- a/README.my.md +++ b/README.my.md @@ -56,6 +56,8 @@ ## 📢 Berita +2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**! 2026-03-17 🚀 **v0.2.3 Dikeluarkan!** UI dulang sistem (Windows & Linux), pertanyaan status sub-agent (`spawn_status`), muat semula panas Gateway eksperimental, kawalan keselamatan Cron, dan 2 pembetulan keselamatan. PicoClaw mencapai **25K Stars**! @@ -315,9 +317,9 @@ Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfiguras PicoClaw pada Termux -**Pilihan 2: APK (akan datang)** +**Pilihan 2: Pasang APK** -APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan! +Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
Pelancar Terminal (untuk persekitaran terhad sumber) diff --git a/README.pt-br.md b/README.pt-br.md index dfe7cb0f2..bbc5b4957 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -56,6 +56,8 @@ ## 📢 Novidades +2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**! 2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**! @@ -318,9 +320,9 @@ Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuraç PicoClaw on Termux -**Opção 2: Instalação via APK (em breve)** +**Opção 2: Instalação via APK** -Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado! +Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
Terminal Launcher (para ambientes com recursos limitados) diff --git a/README.vi.md b/README.vi.md index 6c8f6ad44..7ae414723 100644 --- a/README.vi.md +++ b/README.vi.md @@ -56,6 +56,8 @@ ## 📢 Tin tức +2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**! 2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**! @@ -318,9 +320,9 @@ Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu PicoClaw on Termux -**Tùy chọn 2: Cài đặt APK (sắp ra mắt)** +**Tùy chọn 2: Cài đặt APK** -Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ! +Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
Terminal Launcher (cho môi trường hạn chế tài nguyên) diff --git a/README.zh.md b/README.zh.md index b92e8e889..569ca1656 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,6 +56,8 @@ ## 📢 新闻 +2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download) + 2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**! 2026-03-17 🚀 **v0.2.3 发布!** 系统托盘 UI(Windows & Linux)、子 Agent 状态查询 (`spawn_status`)、实验性 Gateway 热重载、Cron 安全门控,以及 2 项安全修复。PicoClaw 已达 **25K ⭐**! @@ -318,9 +320,9 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布 PicoClaw on Termux -**方式二:APK 安装(即将推出)** +**方式二:APK 安装** -内置 WebUI 的独立 Android APK 正在开发中,敬请期待! +从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
Terminal Launcher(适用于资源受限环境) From 0f395ce11057d941344c43f32ef95295fe8c178d Mon Sep 17 00:00:00 2001 From: Hua Audio Date: Wed, 1 Apr 2026 06:21:21 +0200 Subject: [PATCH 62/72] Refactor/asr tts (#1939) * refactor: update ASR and TTS implementations * fix lint * Integrating asr/tts models w/ new security config * update documents * add arbitrary whisper transcriptor support * update documents * fix lint * add mimo tts --- .golangci.yaml | 3 + config/config.example.json | 3 + go.mod | 6 + go.sum | 13 +- pkg/agent/loop.go | 70 +++- pkg/audio/asr/README.md | 166 ++++++++++ pkg/audio/asr/README_zh.md | 166 ++++++++++ pkg/audio/asr/agent.go | 252 ++++++++++++++ pkg/audio/asr/agent_test.go | 196 +++++++++++ pkg/audio/asr/asr.go | 131 ++++++++ .../asr/asr_test.go} | 72 +++- .../asr}/audio_model_transcriber.go | 2 +- .../asr}/audio_model_transcriber_test.go | 2 +- .../asr}/elevenlabs_transcriber.go | 10 +- .../asr}/elevenlabs_transcriber_test.go | 10 +- pkg/audio/asr/whisper_transcriber.go | 245 ++++++++++++++ pkg/audio/asr/whisper_transcriber_test.go | 102 ++++++ pkg/audio/ogg.go | 57 ++++ pkg/audio/ogg_test.go | 146 ++++++++ pkg/audio/sentence.go | 96 ++++++ pkg/audio/sentence_test.go | 69 ++++ pkg/audio/tts/README.md | 137 ++++++++ pkg/audio/tts/README_zh.md | 137 ++++++++ pkg/audio/tts/mimo_tts.go | 162 +++++++++ pkg/audio/tts/openai_tts.go | 126 +++++++ pkg/audio/tts/tts.go | 151 +++++++++ pkg/audio/tts/tts_test.go | 247 ++++++++++++++ pkg/bus/bus.go | 28 ++ pkg/bus/types.go | 31 +- pkg/channels/discord/discord.go | 170 ++++++++++ pkg/channels/discord/init.go | 7 +- pkg/channels/discord/voice.go | 313 ++++++++++++++++++ pkg/channels/feishu/common.go | 7 + pkg/channels/line/line.go | 5 + pkg/channels/matrix/matrix.go | 5 + pkg/channels/onebot/onebot.go | 5 + pkg/channels/qq/qq.go | 5 + pkg/channels/telegram/telegram.go | 5 + pkg/channels/voice_capabilities.go | 58 ++++ pkg/channels/weixin/weixin.go | 5 + pkg/config/config.go | 9 +- pkg/config/defaults.go | 3 + pkg/gateway/gateway.go | 54 ++- pkg/providers/factory_provider.go | 13 + pkg/tools/tts_send.go | 82 +++++ pkg/voice/groq_transcriber.go | 151 --------- pkg/voice/groq_transcriber_test.go | 84 ----- pkg/voice/transcriber.go | 68 ---- 48 files changed, 3527 insertions(+), 358 deletions(-) create mode 100644 pkg/audio/asr/README.md create mode 100644 pkg/audio/asr/README_zh.md create mode 100644 pkg/audio/asr/agent.go create mode 100644 pkg/audio/asr/agent_test.go create mode 100644 pkg/audio/asr/asr.go rename pkg/{voice/transcriber_test.go => audio/asr/asr_test.go} (67%) rename pkg/{voice => audio/asr}/audio_model_transcriber.go (99%) rename pkg/{voice => audio/asr}/audio_model_transcriber_test.go (99%) rename pkg/{voice => audio/asr}/elevenlabs_transcriber.go (96%) rename pkg/{voice => audio/asr}/elevenlabs_transcriber_test.go (91%) create mode 100644 pkg/audio/asr/whisper_transcriber.go create mode 100644 pkg/audio/asr/whisper_transcriber_test.go create mode 100644 pkg/audio/ogg.go create mode 100644 pkg/audio/ogg_test.go create mode 100644 pkg/audio/sentence.go create mode 100644 pkg/audio/sentence_test.go create mode 100644 pkg/audio/tts/README.md create mode 100644 pkg/audio/tts/README_zh.md create mode 100644 pkg/audio/tts/mimo_tts.go create mode 100644 pkg/audio/tts/openai_tts.go create mode 100644 pkg/audio/tts/tts.go create mode 100644 pkg/audio/tts/tts_test.go create mode 100644 pkg/channels/discord/voice.go create mode 100644 pkg/channels/voice_capabilities.go create mode 100644 pkg/tools/tts_send.go delete mode 100644 pkg/voice/groq_transcriber.go delete mode 100644 pkg/voice/groq_transcriber_test.go delete mode 100644 pkg/voice/transcriber.go diff --git a/.golangci.yaml b/.golangci.yaml index ea3107ec8..b2b772406 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -61,6 +61,9 @@ linters: - usestdlibvars - usetesting settings: + gomoddirectives: + replace-allow-list: + - github.com/bwmarrin/discordgo errcheck: check-type-assertions: true check-blank: true diff --git a/config/config.example.json b/config/config.example.json index 814c82503..95fe24e0b 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -418,6 +418,9 @@ "read_file": { "enabled": true }, + "send_tts": { + "enabled": false + }, "spawn": { "enabled": true }, diff --git a/go.mod b/go.mod index 7d242d498..5f311306e 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,8 @@ require ( github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 + github.com/pion/rtp v1.8.7 + github.com/pion/webrtc/v3 v3.3.6 github.com/rivo/tview v0.42.0 github.com/rs/zerolog v1.34.0 github.com/slack-go/slack v0.17.3 @@ -61,6 +63,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -76,6 +79,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect + github.com/pion/randutil v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -123,3 +127,5 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 ) + +replace github.com/bwmarrin/discordgo => github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 diff --git a/go.sum b/go.sum index 76d1b46c7..ca5dd0423 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,6 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= -github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= -github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -65,6 +63,8 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG github.com/caarlos0/env/v11 v11.4.0/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/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 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= @@ -204,6 +204,12 @@ github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixi github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM= +github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE= +github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -273,6 +279,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o= +github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532/go.mod h1:A0FcMFJKJ9fRjgSuZ2o+pIQ6mPS81SVuiLN2vYTa7Ao= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -300,7 +308,6 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d7461e76f..b376ed0af 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -18,6 +18,8 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" @@ -31,7 +33,6 @@ import ( "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type AgentLoop struct { @@ -51,7 +52,7 @@ type AgentLoop struct { fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore - transcriber voice.Transcriber + transcriber asr.Transcriber cmdRegistry *commands.Registry mcp mcpRuntime hookRuntime hookRuntime @@ -159,6 +160,13 @@ func registerSharedTools( provider providers.LLMProvider, ) { allowReadPaths := buildAllowReadPatterns(cfg) + var ttsProvider tts.TTSProvider + if cfg.Tools.IsToolEnabled("send_tts") { + ttsProvider = tts.DetectTTS(cfg) + if ttsProvider == nil { + logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) + } + } for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) @@ -269,6 +277,10 @@ func registerSharedTools( agent.Tools.Register(sendFileTool) } + if ttsProvider != nil { + agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) + } + // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") @@ -1059,10 +1071,15 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) { agent.Tools.SetMediaStore(s) } } + registry.ForEachTool("send_tts", func(t tools.Tool) { + if st, ok := t.(*tools.SendTTSTool); ok { + st.SetMediaStore(s) + } + }) } // SetTranscriber injects a voice transcriber for agent-level audio transcription. -func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { +func (al *AgentLoop) SetTranscriber(t asr.Transcriber) { al.transcriber = t } @@ -1083,19 +1100,23 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou // Transcribe each audio media ref in order. var transcriptions []string + var keptMedia []string for _, ref := range msg.Media { path, meta, err := al.mediaStore.ResolveWithMeta(ref) if err != nil { logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + keptMedia = append(keptMedia, ref) continue } if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + keptMedia = append(keptMedia, ref) continue } result, err := al.transcriber.Transcribe(ctx, path) if err != nil { logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) transcriptions = append(transcriptions, "") + keptMedia = append(keptMedia, ref) continue } transcriptions = append(transcriptions, result.Text) @@ -1115,15 +1136,21 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou } text := transcriptions[idx] idx++ + if text == "" { + return match + } return "[voice: " + text + "]" }) // Append any remaining transcriptions not matched by an annotation. for ; idx < len(transcriptions); idx++ { - newContent += "\n[voice: " + transcriptions[idx] + "]" + if transcriptions[idx] != "" { + newContent += "\n[voice: " + transcriptions[idx] + "]" + } } msg.Content = newContent + msg.Media = keptMedia return msg, true } @@ -2464,6 +2491,28 @@ turnLoop: if toolResult == nil { toolResult = tools.ErrorResult("hook returned nil tool result") } + + // Send ForUser if not silent and has content. + // For ResponseHandled tools, send regardless of SendResponse setting, + // since they've already handled the response (e.g., send_tts, send_file). + shouldSendForUser := !toolResult.Silent && toolResult.ForUser != "" && + (ts.opts.SendResponse || toolResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Content: toolResult.ForUser, + Metadata: map[string]string{ + "is_tool_call": "true", + }, + }) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{ + "tool": toolName, + "content_len": len(toolResult.ForUser), + }) + } + if len(toolResult.Media) > 0 && toolResult.ResponseHandled { parts := make([]bus.MediaPart, 0, len(toolResult.Media)) for _, ref := range toolResult.Media { @@ -2509,19 +2558,6 @@ turnLoop: allResponsesHandled = false } - if !toolResult.Silent && toolResult.ForUser != "" && ts.opts.SendResponse { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: toolResult.ForUser, - }) - logger.DebugCF("agent", "Sent tool result to user", - map[string]any{ - "tool": toolName, - "content_len": len(toolResult.ForUser), - }) - } - contentForLLM := toolResult.ContentForLLM() // Filter sensitive data (API keys, tokens, secrets) before sending to LLM diff --git a/pkg/audio/asr/README.md b/pkg/audio/asr/README.md new file mode 100644 index 000000000..0477276dd --- /dev/null +++ b/pkg/audio/asr/README.md @@ -0,0 +1,166 @@ +# ASR (Automatic Speech Recognition) + +This package handles speech-to-text for PicoClaw voice input. + +If you are new to ASR setup, the simplest mental model is: + +1. Add one or more ASR-capable entries to `model_list`. +2. Point `voice.model_name` at the one you want to use. +3. Put the API key in `.security.yml`. + +## Quick Recommendation + +For most new users, start with one of these: + +| Provider | Example model | Why start here | +| --- | --- | --- | +| [Groq](https://console.groq.com/keys) | `groq/whisper-large-v3-turbo` | Fast Whisper-style transcription and a straightforward OpenAI-compatible API. Groq currently advertises a free tier plan for 2000 reqs/day. | +| [ElevenLabs](https://elevenlabs.io/pricing) | `elevenlabs/scribe_v1` | Easy setup and strong speech-to-text quality. ElevenLabs currently advertises a free plan that includes speech-to-text usage. | + +Pricing and free-plan limits can change, so check the linked pricing pages before depending on them in production. + +## How ASR Configuration Works + +PicoClaw does not keep ASR API keys inside the `voice` section. + +Instead: + +- `voice.model_name` chooses a named entry from `model_list`. +- The matching `model_list` entry describes the actual provider and model. +- `.security.yml` stores the API key for that named model entry. + +This is the recommended pattern because it is explicit, reusable, and consistent with the rest of PicoClaw's model configuration. + +## Recommended Setup + +### Option A: Groq Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "groq-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "groq-asr", + "model": "groq/whisper-large-v3-turbo" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + groq-asr: + api_keys: + - "gsk_your_groq_key" +``` + +Notes: + +- You can omit `api_base` and PicoClaw will use Groq's default API base automatically. +- If you set `api_base` manually for Groq Whisper, both of these forms work: + - `https://api.groq.com/openai/v1` + - `https://api.groq.com/openai/v1/audio/transcriptions` +- Any OpenAI-compatible Whisper model name containing `whisper` can use the Whisper transcription path, not only `whisper-large-v3-turbo`. + +### Option B: ElevenLabs + +`config.json` + +```json +{ + "voice": { + "model_name": "elevenlabs-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "elevenlabs-asr", + "model": "elevenlabs/scribe_v1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + elevenlabs-asr: + api_keys: + - "sk-elevenlabs-your-key" +``` + +### Option C: OpenAI Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "openai-asr" + }, + "model_list": [ + { + "model_name": "openai-asr", + "model": "openai/whisper-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-asr: + api_keys: + - "sk-openai-your-key" +``` + +## Other ASR-Capable Model Types + +PicoClaw currently supports three main ASR routes: + +| Route | Example models | Behavior | +| --- | --- | --- | +| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. | +| Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. | +| Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. | + +If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first. + +## How PicoClaw Chooses a Transcriber + +`DetectTranscriber` resolves ASR in this order: + +1. **Preferred path**: resolve `voice.model_name` against `model_list`. +2. If that resolved model is: + - `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber. + - an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber. + - an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. +3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries. + +Fallback scanning exists for backward compatibility. New configurations should set `voice.model_name` explicitly. + +## Common Mistakes + +- Defining an ASR model in `model_list` but forgetting to set `voice.model_name`. +- Putting the API key in `voice` instead of `.security.yml`. +- Using a non-ASR model and expecting Whisper-style transcription behavior. +- Setting a custom `api_base` that points to the wrong provider endpoint. + +## Minimal Checklist + +Before testing voice input, make sure: + +- `voice.model_name` matches a `model_list[].model_name`. +- The matching `.security.yml` entry contains a valid API key. +- The selected model is actually ASR-capable. +- Voice input is enabled for the channel you are using. diff --git a/pkg/audio/asr/README_zh.md b/pkg/audio/asr/README_zh.md new file mode 100644 index 000000000..104116080 --- /dev/null +++ b/pkg/audio/asr/README_zh.md @@ -0,0 +1,166 @@ +# ASR(自动语音识别) + +这个目录负责 PicoClaw 的语音转文字能力。 + +如果你是第一次配置 ASR,可以参考如下步骤: + +1. 在 `model_list` 里添加一个或多个支持 ASR 的模型条目。 +2. 用 `voice.model_name` 指向你想使用的那个条目。 +3. 在 `.security.yml` 里配置对应的 API Key。 + +## 快速推荐 + +对于大多数新用户,建议先从下面两种开始: + +| 提供商 | 示例模型 | 推荐理由 | +| --- | --- | --- | +| [Groq](https://console.groq.com/keys) | `groq/whisper-large-v3-turbo` | Whisper 风格转录速度快,并且提供 OpenAI 兼容接口,配置比较直接。Groq 目前官方提供2000请求每日的免费套餐。 | +| [ElevenLabs](https://elevenlabs.io/pricing) | `elevenlabs/scribe_v1` | 上手简单,语音转文字质量也不错。ElevenLabs 目前官方免费套餐包含 STT 用量。 | + +价格和免费额度可能会变化,正式使用前请以官网定价页为准。 + +## ASR 配置是如何工作的 + +PicoClaw 不会把 ASR 的 API Key 放在 `voice` 配置里。 + +推荐的方式是: + +- `voice.model_name` 用来选择 `model_list` 里的某个命名模型。 +- `model_list` 条目描述真实的提供商和模型。 +- `.security.yml` 负责保存该模型条目的 API Key。 + +这种方式更明确、更安全,也和 PicoClaw 其他模型配置方式保持一致。 + +## 推荐配置方式 + +### 方案 A:Groq Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "groq-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "groq-asr", + "model": "groq/whisper-large-v3-turbo" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + groq-asr: + api_keys: + - "gsk_your_groq_key" +``` + +说明: + +- 你可以不写 `api_base`,PicoClaw 会自动使用 Groq 默认接口地址。 +- 如果你手动设置 Groq Whisper 的 `api_base`,下面两种写法都可以: + - `https://api.groq.com/openai/v1` + - `https://api.groq.com/openai/v1/audio/transcriptions` +- 只要是 OpenAI 兼容、并且模型名里包含 `whisper` 的模型,都可以走 Whisper 转录路径,不仅限于 `whisper-large-v3-turbo`。 + +### 方案 B:ElevenLabs + +`config.json` + +```json +{ + "voice": { + "model_name": "elevenlabs-asr", + "echo_transcription": true + }, + "model_list": [ + { + "model_name": "elevenlabs-asr", + "model": "elevenlabs/scribe_v1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + elevenlabs-asr: + api_keys: + - "sk-elevenlabs-your-key" +``` + +### 方案 C:OpenAI Whisper + +`config.json` + +```json +{ + "voice": { + "model_name": "openai-asr" + }, + "model_list": [ + { + "model_name": "openai-asr", + "model": "openai/whisper-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-asr: + api_keys: + - "sk-openai-your-key" +``` + +## 其他支持 ASR 的模型类型 + +PicoClaw 目前主要支持三种 ASR 路径: + +| 路径 | 示例模型 | 行为说明 | +| --- | --- | --- | +| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | +| Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 | +| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | + +如果你不确定该选哪种,建议优先使用 Groq Whisper 或 ElevenLabs。 + +## PicoClaw 如何选择转录器 + +`DetectTranscriber` 会按下面顺序选择 ASR: + +1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。 +2. 如果找到的模型属于以下类型: + - `elevenlabs/...`,则使用 ElevenLabs transcriber。 + - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 + - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。 +3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 + +回退扫描只是为了兼容旧行为。新配置建议始终显式设置 `voice.model_name`。 + +## 常见错误 + +- 在 `model_list` 里定义了 ASR 模型,但忘了设置 `voice.model_name`。 +- 把 API Key 写进了 `voice`,而不是 `.security.yml`。 +- 选择了不支持 ASR 的模型,却期望得到 Whisper 风格的转录结果。 +- 自定义了错误的 `api_base`,导致请求打到错误的接口地址。 + +## 最小检查清单 + +在测试语音输入前,请确认: + +- `voice.model_name` 能正确匹配某个 `model_list[].model_name`。 +- `.security.yml` 中对应条目已经配置了有效 API Key。 +- 你选择的模型确实支持 ASR。 +- 你当前使用的频道已经启用了语音输入能力。 diff --git a/pkg/audio/asr/agent.go b/pkg/audio/asr/agent.go new file mode 100644 index 000000000..32ce0c92a --- /dev/null +++ b/pkg/audio/asr/agent.go @@ -0,0 +1,252 @@ +package asr + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v3/pkg/media/oggwriter" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type speechAccumulator struct { + writer *oggwriter.OggWriter + file string + lastAudioAt time.Time + mu sync.Mutex + closed bool + chatID string + speakerID string + sessionID string + channel string +} + +func (a *speechAccumulator) Push(chunk bus.AudioChunk) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.closed { + return + } + + a.lastAudioAt = time.Now() + + pkt := &rtp.Packet{ + Header: rtp.Header{ + SequenceNumber: uint16(chunk.Sequence), + Timestamp: chunk.Timestamp, + SSRC: 1, // Stable arbitrary dummy + }, + Payload: chunk.Data, + } + + if err := a.writer.WriteRTP(pkt); err != nil { + logger.ErrorCF("voice-agent", "Failed to write RTP", map[string]any{"error": err}) + } +} + +func (a *speechAccumulator) Close() { + a.mu.Lock() + defer a.mu.Unlock() + if !a.closed { + a.writer.Close() + a.closed = true + } +} + +type Agent struct { + bus *bus.MessageBus + transcriber Transcriber + + mu sync.Mutex + sessions map[string]*speechAccumulator // keyed by sessionID_speakerID +} + +func NewAgent(mb *bus.MessageBus, t Transcriber) *Agent { + return &Agent{ + bus: mb, + transcriber: t, + sessions: make(map[string]*speechAccumulator), + } +} + +func (a *Agent) Start(ctx context.Context) { + logger.InfoCF("voice-agent", "Started Voice Agent orchestrator", nil) + go a.listenChunks(ctx) + go a.vadTick(ctx) + + // Cleanup sessions on shutdown + go func() { + <-ctx.Done() + a.mu.Lock() + for key, acc := range a.sessions { + acc.Close() + os.Remove(acc.file) + delete(a.sessions, key) + } + a.mu.Unlock() + logger.InfoCF("voice-agent", "Cleaned up voice sessions on shutdown", nil) + }() +} + +func (a *Agent) listenChunks(ctx context.Context) { + chunks := a.bus.AudioChunksChan() + for { + select { + case <-ctx.Done(): + return + case chunk, ok := <-chunks: + if !ok { + return + } + a.handleChunk(chunk) + } + } +} + +func (a *Agent) handleChunk(chunk bus.AudioChunk) { + // Only accept Opus-encoded audio + if chunk.Format != "opus" { + logger.DebugCF("voice-agent", "Ignoring unsupported audio format", map[string]any{"format": chunk.Format}) + return + } + + key := fmt.Sprintf("%s_%s", chunk.SessionID, chunk.SpeakerID) + + a.mu.Lock() + acc, exists := a.sessions[key] + if !exists { + filename := filepath.Join(os.TempDir(), fmt.Sprintf("voice_%s_%d.ogg", key, time.Now().UnixNano())) + writer, err := oggwriter.New(filename, uint32(chunk.SampleRate), uint16(chunk.Channels)) + if err != nil { + a.mu.Unlock() + logger.ErrorCF("voice-agent", "Failed to create OggWriter", map[string]any{"error": err}) + return + } + + acc = &speechAccumulator{ + writer: writer, + file: filename, + lastAudioAt: time.Now(), + chatID: chunk.ChatID, + speakerID: chunk.SpeakerID, + sessionID: chunk.SessionID, + channel: chunk.Channel, + } + a.sessions[key] = acc + logger.DebugCF("voice-agent", "Started accumulating voice", map[string]any{"key": key, "file": filename}) + } + a.mu.Unlock() + + acc.Push(chunk) +} + +func (a *Agent) vadTick(ctx context.Context) { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.checkSilence(ctx) + } + } +} + +func (a *Agent) checkSilence(ctx context.Context) { + a.mu.Lock() + now := time.Now() + var finished []*speechAccumulator + + for key, acc := range a.sessions { + acc.mu.Lock() + last := acc.lastAudioAt + acc.mu.Unlock() + + if now.Sub(last) > 1500*time.Millisecond { + acc.Close() + delete(a.sessions, key) + finished = append(finished, acc) + } + } + a.mu.Unlock() + + for _, acc := range finished { + go a.processUtterance(ctx, acc) + } +} + +func (a *Agent) processUtterance(ctx context.Context, acc *speechAccumulator) { + defer os.Remove(acc.file) + + logger.InfoCF("voice-agent", "User finished speaking, transcribing...", map[string]any{"file": acc.file}) + + if a.transcriber == nil { + logger.ErrorCF("voice-agent", "No STT configured!", nil) + return + } + + res, err := a.transcriber.Transcribe(ctx, acc.file) + if err != nil { + logger.ErrorCF("voice-agent", "Transcription failed", map[string]any{"error": err}) + return + } + + if res.Text == "" { + logger.DebugCF("voice-agent", "Ignored empty transcription", map[string]any{"file": acc.file}) + return + } + + logger.InfoCF("voice-agent", "Transcription result", map[string]any{"text": res.Text, "duration": res.Duration}) + + channelType := acc.channel + if channelType == "" { + channelType = "discord" // fallback for legacy chunks + } + + text := strings.ToLower(strings.TrimSpace(res.Text)) + if strings.Contains(text, "leave the voice channel") || strings.Contains(text, "leave voice") || + strings.Contains(text, "disconnect voice") || strings.Contains(text, "leave the channel") || + strings.Contains(text, "leave channel") { + logger.InfoCF("voice-agent", "Voice command triggered: leave", nil) + if err := a.bus.PublishVoiceControl(ctx, bus.VoiceControl{ + SessionID: acc.sessionID, + Type: "command", + Action: "leave", + }); err != nil { + logger.ErrorCF("voice-agent", "Failed to publish leave control", map[string]any{"error": err}) + } + if err := a.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: channelType, + ChatID: acc.chatID, + Content: "Goodbye! Leaving the voice channel.", + }); err != nil { + logger.ErrorCF("voice-agent", "Failed to publish goodbye message", map[string]any{"error": err}) + } + return + } + + oralPrompt := "\n\n[SYSTEM]: The user just spoke this to you over voice chat. Please reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally." + + if err := a.bus.PublishInbound(ctx, bus.InboundMessage{ + Channel: channelType, + SenderID: acc.speakerID, + ChatID: acc.chatID, + Content: res.Text + oralPrompt, + Peer: bus.Peer{Kind: "channel", ID: acc.chatID}, + Metadata: map[string]string{ + "is_voice": "true", + }, + }); err != nil { + logger.ErrorCF("voice-agent", "Failed to publish inbound message", map[string]any{"error": err}) + } +} diff --git a/pkg/audio/asr/agent_test.go b/pkg/audio/asr/agent_test.go new file mode 100644 index 000000000..cc1b008a4 --- /dev/null +++ b/pkg/audio/asr/agent_test.go @@ -0,0 +1,196 @@ +package asr + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pion/webrtc/v3/pkg/media/oggwriter" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +type fakeTranscriber struct { + text string + err error + lastPath string +} + +func (f *fakeTranscriber) Name() string { return "fake" } + +func (f *fakeTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + f.lastPath = audioFilePath + if f.err != nil { + return nil, f.err + } + return &TranscriptionResponse{Text: f.text}, nil +} + +func waitForFileRemoval(t *testing.T, path string, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return + } + time.Sleep(10 * time.Millisecond) + } + if _, err := os.Stat(path); err == nil { + t.Fatalf("expected file to be removed: %s", path) + } +} + +func TestAgentHandleChunkCreatesSession(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + agent := NewAgent(mb, &fakeTranscriber{}) + + chunk := bus.AudioChunk{ + SessionID: "sess", + SpeakerID: "speaker", + ChatID: "chat", + Channel: "discord", + Sequence: 1, + Timestamp: 1, + SampleRate: 48000, + Channels: 2, + Format: "opus", + Data: []byte{0xF8, 0xFF, 0xFE}, + } + + agent.handleChunk(chunk) + + key := "sess_speaker" + agent.mu.Lock() + acc, ok := agent.sessions[key] + agent.mu.Unlock() + if !ok { + t.Fatal("expected session to be created") + } + + acc.Close() + _ = os.Remove(acc.file) +} + +func TestAgentHandleChunkIgnoresUnsupportedFormat(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + agent := NewAgent(mb, &fakeTranscriber{}) + + chunk := bus.AudioChunk{Format: "pcm"} + agent.handleChunk(chunk) + + agent.mu.Lock() + count := len(agent.sessions) + agent.mu.Unlock() + if count != 0 { + t.Fatalf("expected no sessions, got %d", count) + } +} + +func TestAgentProcessUtteranceLeaveCommand(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + tr := &fakeTranscriber{text: "please leave the voice channel now"} + agent := NewAgent(mb, tr) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "voice.ogg") + if err := os.WriteFile(filePath, []byte("data"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + acc := &speechAccumulator{ + file: filePath, + chatID: "chat", + speakerID: "speaker", + sessionID: "sess", + channel: "discord", + } + + agent.processUtterance(context.Background(), acc) + + select { + case ctrl := <-mb.VoiceControlsChan(): + if ctrl.Action != "leave" || ctrl.Type != "command" || ctrl.SessionID != "sess" { + t.Fatalf("unexpected voice control: %#v", ctrl) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("expected voice control publish") + } + + select { + case out := <-mb.OutboundChan(): + if !strings.Contains(out.Content, "Leaving the voice channel") { + t.Fatalf("unexpected outbound content: %q", out.Content) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("expected outbound publish") + } + + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("expected temp file to be removed") + } +} + +func TestAgentCheckSilencePublishesInboundAndCleansUp(t *testing.T) { + t.Parallel() + + mb := bus.NewMessageBus() + defer mb.Close() + + tr := &fakeTranscriber{text: "hello there"} + agent := NewAgent(mb, tr) + + filePath := filepath.Join(t.TempDir(), "voice.ogg") + writer, err := oggwriter.New(filePath, 48000, 2) + if err != nil { + t.Fatalf("create ogg writer: %v", err) + } + + acc := &speechAccumulator{ + writer: writer, + file: filePath, + lastAudioAt: time.Now().Add(-2 * time.Second), + chatID: "chat", + speakerID: "speaker", + sessionID: "sess", + channel: "slack", + } + + agent.mu.Lock() + agent.sessions["sess_speaker"] = acc + agent.mu.Unlock() + + agent.checkSilence(context.Background()) + + select { + case msg := <-mb.InboundChan(): + if msg.Channel != "slack" { + t.Fatalf("unexpected inbound channel: %q", msg.Channel) + } + if !strings.Contains(msg.Content, "hello there") { + t.Fatalf("unexpected inbound content: %q", msg.Content) + } + if msg.Metadata["is_voice"] != "true" { + t.Fatalf("expected is_voice metadata, got %#v", msg.Metadata) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("expected inbound publish") + } + + waitForFileRemoval(t, filePath, 500*time.Millisecond) +} diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go new file mode 100644 index 000000000..d15dc3f09 --- /dev/null +++ b/pkg/audio/asr/asr.go @@ -0,0 +1,131 @@ +package asr + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type Transcriber interface { + Name() string + Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) +} + +type TranscriptionResponse struct { + Text string `json:"text"` + Language string `json:"language,omitempty"` + Duration float64 `json:"duration,omitempty"` +} + +func supportsAudioTranscription(model string) bool { + protocol, _ := providers.ExtractProtocol(model) + + switch protocol { + case "openai", "azure", "azure-openai", + "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding": + // These protocols all go through the OpenAI-compatible or Azure provider path in + // providers.CreateProviderFromConfig, so they are the only ones that can supply + // the audio media payload shape expected by NewAudioModelTranscriber. + + // TODO: Further restrict this by modelID, since not every model under these + // protocols supports audio transcription. + return true + default: + return false + } +} + +func supportsWhisperTranscription(model string) bool { + protocol, _ := providers.ExtractProtocol(model) + + switch protocol { + case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", + "coding-plan", "alibaba-coding", "qwen-coding", "mimo": + return true + default: + return false + } +} + +func whisperModelID(modelCfg *config.ModelConfig) string { + if modelCfg == nil || modelCfg.APIKey() == "" { + return "" + } + + if !supportsWhisperTranscription(modelCfg.Model) { + return "" + } + + _, modelID := providers.ExtractProtocol(strings.TrimSpace(modelCfg.Model)) + if strings.Contains(strings.ToLower(modelID), "whisper") { + return modelID + } + return "" +} + +func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { + if modelCfg == nil { + return nil + } + + protocol, _ := providers.ExtractProtocol(modelCfg.Model) + if protocol == "elevenlabs" && modelCfg.APIKey() != "" { + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + } + if modelID := whisperModelID(modelCfg); modelID != "" { + return NewWhisperTranscriber(modelCfg) + } + if supportsAudioTranscription(modelCfg.Model) { + return NewAudioModelTranscriber(modelCfg) + } + return nil +} + +func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { + if modelCfg == nil { + return nil + } + + protocol, _ := providers.ExtractProtocol(modelCfg.Model) + if protocol == "elevenlabs" && modelCfg.APIKey() != "" { + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + } + if modelID := whisperModelID(modelCfg); modelID != "" { + return NewWhisperTranscriber(modelCfg) + } + return nil +} + +// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or +// nil if no supported transcription provider is configured. +func DetectTranscriber(cfg *config.Config) Transcriber { + if cfg == nil { + return nil + } + + if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" { + modelCfg, err := cfg.GetModelConfig(modelName) + if err == nil { + if tr := transcriberFromModelConfig(modelCfg); tr != nil { + return tr + } + } + } + + // Fall back to compatibility scanning for legacy auto-detected ASR providers. + for _, mc := range cfg.ModelList { + if tr := fallbackTranscriberFromModelConfig(mc); tr != nil { + return tr + } + } + return nil +} diff --git a/pkg/voice/transcriber_test.go b/pkg/audio/asr/asr_test.go similarity index 67% rename from pkg/voice/transcriber_test.go rename to pkg/audio/asr/asr_test.go index 3e71ff13a..0970d69f4 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/audio/asr/asr_test.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "testing" @@ -33,26 +33,68 @@ func TestDetectTranscriber(t *testing.T) { wantName: "audio-model", }, { - name: "groq via model list", + name: "voice model name alias selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, + { + name: "voice model name alias selects whisper transcriber for groq", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "groq/whisper-large-v3", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), + }, + }, + }, + wantName: "whisper", + }, + { + name: "openai whisper alias selects whisper transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Model: "openai/whisper-1", + APIKeys: config.SimpleSecureStrings("sk-openai-model"), + }, + }, + }, + wantName: "whisper", + }, + { + name: "whisper via model list fallback", cfg: &config.Config{ ModelList: []*config.ModelConfig{ {ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")}, { ModelName: "groq", - Model: "groq/llama-3.3-70b", + Model: "groq/whisper-large-v3-turbo", APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, }, - wantName: "groq", + wantName: "whisper", }, { - name: "voice model name selects non-gemini audio model transcriber", + name: "voice model name alias selects non-gemini audio model transcriber", cfg: &config.Config{ - Voice: config.VoiceConfig{ModelName: "voice-openai-audio"}, + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, ModelList: []*config.ModelConfig{ { - ModelName: "voice-openai-audio", + ModelName: "my-asr-model", Model: "openai/gpt-4o-audio-preview", APIKeys: config.SimpleSecureStrings("sk-openai"), }, @@ -92,7 +134,7 @@ func TestDetectTranscriber(t *testing.T) { name: "groq model list entry without key is skipped", cfg: &config.Config{ ModelList: []*config.ModelConfig{ - {Model: "groq/llama-3.3-70b"}, + {Model: "groq/whisper-large-v3"}, }, }, wantNil: true, @@ -103,12 +145,12 @@ func TestDetectTranscriber(t *testing.T) { ModelList: []*config.ModelConfig{ { ModelName: "groq", - Model: "groq/llama-3.3-70b", + Model: "groq/whisper-large-v3", APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, }, - wantName: "groq", + wantName: "whisper", }, { name: "missing voice model name config returns nil", @@ -127,15 +169,17 @@ func TestDetectTranscriber(t *testing.T) { { name: "elevenlabs voice config key", cfg: &config.Config{ - Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, + ModelList: []*config.ModelConfig{ + {Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, + }, }, wantName: "elevenlabs", }, { name: "elevenlabs takes priority over groq model list", cfg: &config.Config{ - Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, ModelList: []*config.ModelConfig{ + {Model: "elevenlabs/scribe_v1", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, { ModelName: "groq", Model: "groq/llama-3.3-70b", @@ -149,10 +193,10 @@ func TestDetectTranscriber(t *testing.T) { name: "voice model name takes priority over elevenlabs", cfg: &config.Config{ Voice: config.VoiceConfig{ - ModelName: "voice-gemini", - ElevenLabsAPIKey: "sk_elevenlabs_test", + ModelName: "voice-gemini", }, ModelList: []*config.ModelConfig{ + {Model: "elevenlabs", APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test")}, { ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash", diff --git a/pkg/voice/audio_model_transcriber.go b/pkg/audio/asr/audio_model_transcriber.go similarity index 99% rename from pkg/voice/audio_model_transcriber.go rename to pkg/audio/asr/audio_model_transcriber.go index f3ca81961..e8ded15dd 100644 --- a/pkg/voice/audio_model_transcriber.go +++ b/pkg/audio/asr/audio_model_transcriber.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "context" diff --git a/pkg/voice/audio_model_transcriber_test.go b/pkg/audio/asr/audio_model_transcriber_test.go similarity index 99% rename from pkg/voice/audio_model_transcriber_test.go rename to pkg/audio/asr/audio_model_transcriber_test.go index c33e3bf97..5aaa82061 100644 --- a/pkg/voice/audio_model_transcriber_test.go +++ b/pkg/audio/asr/audio_model_transcriber_test.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "context" diff --git a/pkg/voice/elevenlabs_transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go similarity index 96% rename from pkg/voice/elevenlabs_transcriber.go rename to pkg/audio/asr/elevenlabs_transcriber.go index 93db10f8d..452b9512d 100644 --- a/pkg/voice/elevenlabs_transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "bytes" @@ -23,12 +23,16 @@ type ElevenLabsTranscriber struct { httpClient *http.Client } -func NewElevenLabsTranscriber(apiKey string) *ElevenLabsTranscriber { +func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber { logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) + if apiBase == "" { + apiBase = "https://api.elevenlabs.io" + } + return &ElevenLabsTranscriber{ apiKey: apiKey, - apiBase: "https://api.elevenlabs.io", + apiBase: apiBase, httpClient: &http.Client{ Timeout: 120 * time.Second, }, diff --git a/pkg/voice/elevenlabs_transcriber_test.go b/pkg/audio/asr/elevenlabs_transcriber_test.go similarity index 91% rename from pkg/voice/elevenlabs_transcriber_test.go rename to pkg/audio/asr/elevenlabs_transcriber_test.go index 78be8958a..fa80110be 100644 --- a/pkg/voice/elevenlabs_transcriber_test.go +++ b/pkg/audio/asr/elevenlabs_transcriber_test.go @@ -1,4 +1,4 @@ -package voice +package asr import ( "context" @@ -14,7 +14,7 @@ import ( var _ Transcriber = (*ElevenLabsTranscriber)(nil) func TestElevenLabsTranscriberName(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test") + tr := NewElevenLabsTranscriber("sk_test", "") if got := tr.Name(); got != "elevenlabs" { t.Errorf("Name() = %q, want %q", got, "elevenlabs") } @@ -43,7 +43,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_test") + tr := NewElevenLabsTranscriber("sk_test", "") tr.apiBase = srv.URL resp, err := tr.Transcribe(context.Background(), audioPath) @@ -64,7 +64,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_bad") + tr := NewElevenLabsTranscriber("sk_bad", "") tr.apiBase = srv.URL _, err := tr.Transcribe(context.Background(), audioPath) @@ -74,7 +74,7 @@ func TestElevenLabsTranscribe(t *testing.T) { }) t.Run("missing file", func(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test") + tr := NewElevenLabsTranscriber("sk_test", "") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil") diff --git a/pkg/audio/asr/whisper_transcriber.go b/pkg/audio/asr/whisper_transcriber.go new file mode 100644 index 000000000..406710a8a --- /dev/null +++ b/pkg/audio/asr/whisper_transcriber.go @@ -0,0 +1,245 @@ +package asr + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type WhisperTranscriber struct { + apiKey string + apiBase string + modelID string + providerName string + httpClient *http.Client +} + +func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber { + if modelCfg == nil { + return nil + } + + protocol, modelID := providers.ExtractProtocol(modelCfg.Model) + if modelID == "" { + modelID = strings.TrimSpace(modelCfg.Model) + } + + tr := newWhisperTranscriber( + modelCfg.APIKey(), + providers.ResolveAPIBase(modelCfg), + modelID, + protocol, + ) + if tr == nil { + return nil + } + + logger.DebugCF("voice", "Creating whisper transcriber", map[string]any{ + "api_base": tr.apiBase, + "has_key": tr.apiKey != "", + "model": tr.modelID, + "provider": tr.providerName, + }) + return tr +} + +func NewGroqTranscriber(apiKey, modelID string) *WhisperTranscriber { + return newWhisperTranscriber(apiKey, "https://api.groq.com/openai/v1", modelID, "groq") +} + +func newWhisperTranscriber(apiKey, apiBase, modelID, providerName string) *WhisperTranscriber { + if modelID == "" { + return nil + } + if providerName == "" { + providerName = "whisper" + } + return &WhisperTranscriber{ + apiKey: apiKey, + apiBase: strings.TrimRight(apiBase, "/"), + modelID: modelID, + providerName: providerName, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (t *WhisperTranscriber) transcriptionURL() string { + base := strings.TrimRight(t.apiBase, "/") + if strings.HasSuffix(base, "/audio/transcriptions") { + return base + } + return base + "/audio/transcriptions" +} + +func (t *WhisperTranscriber) TranscribeData( + ctx context.Context, + data []byte, + filename string, +) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting whisper transcription from memory", map[string]any{ + "bytes": len(data), + "filename": filename, + "model": t.modelID, + "provider": t.providerName, + }) + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + logger.ErrorCF("voice", "Failed to create whisper form file", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, copyErr := io.Copy(part, bytes.NewReader(data)); copyErr != nil { + logger.ErrorCF("voice", "Failed to copy whisper file content", map[string]any{"error": copyErr}) + return nil, fmt.Errorf("failed to copy file content: %w", copyErr) + } + + if err = writer.WriteField("model", t.modelID); err != nil { + logger.ErrorCF("voice", "Failed to write whisper model field", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to write model field: %w", err) + } + + if err = writer.WriteField("response_format", "json"); err != nil { + logger.ErrorCF("voice", "Failed to write whisper response_format field", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to write response_format field: %w", err) + } + + if err = writer.Close(); err != nil { + logger.ErrorCF("voice", "Failed to close whisper multipart writer", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + return t.doRequest(ctx, &requestBody, writer.FormDataContentType(), int64(len(data))) +} + +func (t *WhisperTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting whisper transcription", map[string]any{ + "audio_file": audioFilePath, + "model": t.modelID, + "provider": t.providerName, + }) + + audioFile, err := os.Open(audioFilePath) + if err != nil { + return nil, fmt.Errorf("failed to open audio file %s: %w", audioFilePath, err) + } + defer audioFile.Close() + + fileInfo, err := audioFile.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat audio file %s: %w", audioFilePath, err) + } + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) + if err != nil { + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, copyErr := io.Copy(part, audioFile); copyErr != nil { + return nil, fmt.Errorf("failed to copy audio data: %w", copyErr) + } + + if err = writer.WriteField("model", t.modelID); err != nil { + return nil, fmt.Errorf("failed to write model field: %w", err) + } + + if err = writer.WriteField("response_format", "json"); err != nil { + return nil, fmt.Errorf("failed to write response_format field: %w", err) + } + + if err = writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + return t.doRequest(ctx, &requestBody, writer.FormDataContentType(), fileInfo.Size()) +} + +func (t *WhisperTranscriber) doRequest( + ctx context.Context, + requestBody *bytes.Buffer, + contentType string, + fileSize int64, +) (*TranscriptionResponse, error) { + url := t.transcriptionURL() + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) + if err != nil { + logger.ErrorCF("voice", "Failed to create whisper request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", contentType) + if t.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+t.apiKey) + } + + logger.DebugCF("voice", "Sending whisper transcription request", map[string]any{ + "file_size_bytes": fileSize, + "model": t.modelID, + "provider": t.providerName, + "request_size_bytes": requestBody.Len(), + "url": url, + }) + + resp, err := t.httpClient.Do(req) + if err != nil { + logger.ErrorCF("voice", "Failed to send whisper request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("voice", "Failed to read whisper response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("voice", "Whisper API error", map[string]any{ + "provider": t.providerName, + "response": string(body), + "status_code": resp.StatusCode, + }) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var result TranscriptionResponse + if err := json.Unmarshal(body, &result); err != nil { + logger.ErrorCF("voice", "Failed to unmarshal whisper response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + logger.InfoCF("voice", "Whisper transcription completed successfully", map[string]any{ + "duration_seconds": result.Duration, + "language": result.Language, + "provider": t.providerName, + "text_length": len(result.Text), + "transcription_preview": utils.Truncate(result.Text, 50), + }) + + return &result, nil +} + +func (t *WhisperTranscriber) Name() string { + return "whisper" +} diff --git a/pkg/audio/asr/whisper_transcriber_test.go b/pkg/audio/asr/whisper_transcriber_test.go new file mode 100644 index 000000000..a2a5178d1 --- /dev/null +++ b/pkg/audio/asr/whisper_transcriber_test.go @@ -0,0 +1,102 @@ +package asr + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestWhisperTranscriberTranscribeDataUsesConfiguredModel(t *testing.T) { + var gotModel string + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if got := r.Header.Get("Authorization"); got != "Bearer sk-openai-test" { + t.Errorf("Authorization = %q, want %q", got, "Bearer sk-openai-test") + } + + reader, err := r.MultipartReader() + if err != nil { + t.Fatalf("MultipartReader() error: %v", err) + } + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error: %v", err) + } + + data, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll() error: %v", err) + } + + if part.FormName() == "model" { + gotModel = string(data) + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(TranscriptionResponse{Text: "hello from whisper"}); err != nil { + t.Fatalf("Encode() error: %v", err) + } + })) + defer server.Close() + + tr := NewWhisperTranscriber(&config.ModelConfig{ + Model: "openai/whisper-1", + APIBase: server.URL, + APIKeys: config.SimpleSecureStrings("sk-openai-test"), + }) + tr.httpClient = server.Client() + + resp, err := tr.TranscribeData(context.Background(), []byte("audio"), "clip.ogg") + if err != nil { + t.Fatalf("TranscribeData() error: %v", err) + } + if resp.Text != "hello from whisper" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from whisper") + } + if gotModel != "whisper-1" { + t.Errorf("model field = %q, want %q", gotModel, "whisper-1") + } + if gotPath != "/audio/transcriptions" { + t.Errorf("path = %q, want %q", gotPath, "/audio/transcriptions") + } +} + +func TestWhisperTranscriberUsesEndpointAPIBaseWithoutDoubleAppend(t *testing.T) { + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}); err != nil { + t.Fatalf("Encode() error: %v", err) + } + })) + defer server.Close() + + tr := NewWhisperTranscriber(&config.ModelConfig{ + Model: "groq/whisper-large-v3", + APIBase: server.URL + "/audio/transcriptions", + APIKeys: config.SimpleSecureStrings("sk-groq-test"), + }) + tr.httpClient = server.Client() + + if _, err := tr.TranscribeData(context.Background(), []byte("audio"), "clip.ogg"); err != nil { + t.Fatalf("TranscribeData() error: %v", err) + } + if gotPath != "/audio/transcriptions" { + t.Errorf("path = %q, want %q", gotPath, "/audio/transcriptions") + } +} diff --git a/pkg/audio/ogg.go b/pkg/audio/ogg.go new file mode 100644 index 000000000..f0055a574 --- /dev/null +++ b/pkg/audio/ogg.go @@ -0,0 +1,57 @@ +package audio + +import ( + "bytes" + "fmt" + "io" +) + +// DecodeOggOpus reads an Ogg format stream and extracts individual Opus payloads. +// It calls onFrame for every complete Opus frame found in the stream. +func DecodeOggOpus(r io.Reader, onFrame func([]byte) error) error { + var packet bytes.Buffer + header := make([]byte, 27) + segment := make([]byte, 255) + + for { + if _, err := io.ReadFull(r, header); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil + } + return fmt.Errorf("failed to read ogg header: %w", err) + } + if string(header[:4]) != "OggS" { + return fmt.Errorf("invalid ogg magic string") + } + + pageSegments := int(header[26]) + segmentTable := make([]byte, pageSegments) + if _, err := io.ReadFull(r, segmentTable); err != nil { + return fmt.Errorf("failed to read segment table: %w", err) + } + + for _, lacing := range segmentTable { + if _, err := io.ReadFull(r, segment[:lacing]); err != nil { + return fmt.Errorf("failed to read segment data: %w", err) + } + + packet.Write(segment[:lacing]) + + // If lacing is less than 255, the packet is complete + if lacing < 255 { + if packet.Len() > 0 { + packetBytes := packet.Bytes() + // Ignore Ogg Opus headers + if !bytes.HasPrefix(packetBytes, []byte("OpusHead")) && + !bytes.HasPrefix(packetBytes, []byte("OpusTags")) { + if err := onFrame(packetBytes); err != nil { + return err + } + } + // Start new packet + packet.Reset() + } + } + } + } +} diff --git a/pkg/audio/ogg_test.go b/pkg/audio/ogg_test.go new file mode 100644 index 000000000..8d5e5ac2a --- /dev/null +++ b/pkg/audio/ogg_test.go @@ -0,0 +1,146 @@ +package audio + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +// buildOggPage helper creates an Ogg page for testing. +// lacingVals specifies the segment table, and data is the payload. +func buildOggPage(lacingVals []byte, data []byte) []byte { + var buf bytes.Buffer + // 27-byte Ogg header + header := make([]byte, 27) + copy(header[:4], "OggS") + header[5] = 0 // type flag + // For testing, we only care about OggS magic and page_segments (byte 26) + header[26] = byte(len(lacingVals)) + buf.Write(header) + buf.Write(lacingVals) + buf.Write(data) + return buf.Bytes() +} + +func TestDecodeOggOpus_ValidParsing(t *testing.T) { + var b bytes.Buffer + + // Packet 1: Single segment, length 50 + pkt1 := bytes.Repeat([]byte{1}, 50) + // Packet 2: Multi-segment (255 + 10 = 265 bytes) + pkt2Part1 := bytes.Repeat([]byte{2}, 255) + pkt2Part2 := bytes.Repeat([]byte{2}, 10) + // Packet 3: Continued across pages. Page 1 gets 255, Page 2 gets 20. Total 275 bytes. + pkt3Part1 := bytes.Repeat([]byte{3}, 255) + pkt3Part2 := bytes.Repeat([]byte{3}, 20) + + // Page 1: OpusHead (skip), OpusTags (skip), pkt1, pkt2, pkt3Part1 + page1Lacing := []byte{8, 8, 50, 255, 10, 255} + page1Data := bytes.Join([][]byte{ + []byte("OpusHead"), + []byte("OpusTags"), + pkt1, + pkt2Part1, pkt2Part2, + pkt3Part1, + }, nil) + + // Page 2: pkt3Part2, pkt4 (length 10) + pkt4 := bytes.Repeat([]byte{4}, 10) + page2Lacing := []byte{20, 10} + page2Data := bytes.Join([][]byte{ + pkt3Part2, + pkt4, + }, nil) + + b.Write(buildOggPage(page1Lacing, page1Data)) + b.Write(buildOggPage(page2Lacing, page2Data)) + + var frames [][]byte + err := DecodeOggOpus(&b, func(frame []byte) error { + // making a copy to store as DecodeOggOpus might reuse backing array + cpy := make([]byte, len(frame)) + copy(cpy, frame) + frames = append(frames, cpy) + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedFrames := [][]byte{ + pkt1, + append(pkt2Part1, pkt2Part2...), + append(pkt3Part1, pkt3Part2...), + pkt4, + } + + if len(frames) != len(expectedFrames) { + t.Fatalf("expected %d frames, got %d", len(expectedFrames), len(frames)) + } + + for i, expected := range expectedFrames { + if !reflect.DeepEqual(frames[i], expected) { + t.Errorf("frame %d mismatch:\nexp: %v\ngot: %v", i, expected, frames[i]) + } + } +} + +func TestDecodeOggOpus_Errors(t *testing.T) { + tests := []struct { + name string + data []byte + errContains string + }{ + { + name: "invalid magic string", + data: []byte( + "OggX\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + ), + errContains: "invalid ogg magic string", + }, + { + name: "short header", + data: []byte("Ogg"), + errContains: "failed to read ogg header", + }, + { + name: "eof in segment table", + data: func() []byte { + h := make([]byte, 27) + copy(h, "OggS") + h[26] = 5 // expects 5 bytes of segment table, but none provided + return h + }(), + errContains: "failed to read segment table", + }, + { + name: "eof in segment data", + data: func() []byte { + h := make([]byte, 27, 28) + copy(h, "OggS") + h[26] = 1 + return append(h, 100) // expects 100 bytes of data, but none provided + }(), + errContains: "failed to read segment data", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := DecodeOggOpus(bytes.NewReader(tt.data), func(b []byte) error { return nil }) + if tt.name == "short header" { + if err != nil { + t.Errorf("expected no error (io.EOF/ErrUnexpectedEOF swallowed), got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.errContains) + } + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %q, got: %q", tt.errContains, err.Error()) + } + }) + } +} diff --git a/pkg/audio/sentence.go b/pkg/audio/sentence.go new file mode 100644 index 000000000..89b9ac03e --- /dev/null +++ b/pkg/audio/sentence.go @@ -0,0 +1,96 @@ +package audio + +import ( + "strings" + "unicode" +) + +// SplitSentences splits text into sentence-sized chunks suitable for TTS synthesis. +// It splits on sentence-ending punctuation (.!?\n, as well as CJK 。, !, ?) while avoiding false splits +// on decimal numbers. Very short fragments are merged with +// the next sentence to prevent choppy playback. +func SplitSentences(text string) []string { + if text == "" { + return nil + } + + var sentences []string + var current strings.Builder + runes := []rune(text) + + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '\n' { + s := strings.TrimSpace(current.String()) + if s != "" { + sentences = append(sentences, s) + } + current.Reset() + continue + } + + current.WriteRune(r) + + if r == '.' || r == '!' || r == '?' || r == '。' || r == '!' || r == '?' { + // Avoid splitting on decimal numbers like "3.14" + if r == '.' && i > 0 && unicode.IsDigit(runes[i-1]) && + i+1 < len(runes) && unicode.IsDigit(runes[i+1]) { + continue + } + + // Consume contiguous punctuation clusters (e.g., "..." or "?!"). + for i+1 < len(runes) && (runes[i+1] == '.' || runes[i+1] == '!' || runes[i+1] == '?' || runes[i+1] == '。' || runes[i+1] == '!' || runes[i+1] == '?') { + i++ + current.WriteRune(runes[i]) + } + + s := strings.TrimSpace(current.String()) + if s != "" { + sentences = append(sentences, s) + } + current.Reset() + } + } + + // Flush remaining text + if s := strings.TrimSpace(current.String()); s != "" { + sentences = append(sentences, s) + } + + // Merge very short fragments with the next sentence + return mergeShorties(sentences, 15) +} + +// mergeShorties merges sentences shorter than minLen characters with the following sentence. +func mergeShorties(sentences []string, minLen int) []string { + if len(sentences) <= 1 { + return sentences + } + + var merged []string + var buf string + + for _, s := range sentences { + if buf != "" { + buf += " " + s + if len([]rune(buf)) >= minLen { + merged = append(merged, buf) + buf = "" + } + } else if len([]rune(s)) < minLen { + buf = s + } else { + merged = append(merged, s) + } + } + + if buf != "" { + if len(merged) > 0 { + merged[len(merged)-1] += " " + buf + } else { + merged = append(merged, buf) + } + } + + return merged +} diff --git a/pkg/audio/sentence_test.go b/pkg/audio/sentence_test.go new file mode 100644 index 000000000..54d69e4a6 --- /dev/null +++ b/pkg/audio/sentence_test.go @@ -0,0 +1,69 @@ +package audio + +import ( + "reflect" + "testing" +) + +func TestSplitSentences(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + { + name: "empty input", + in: "", + want: nil, + }, + { + name: "single sentence", + in: "Hello world.", + want: []string{"Hello world."}, + }, + { + name: "decimal numbers do not split", + in: "The value is 3.14 today. Keep watching closely.", + want: []string{"The value is 3.14 today.", "Keep watching closely."}, + }, + { + name: "newline boundary", + in: "This is line number one\nThis is line number two", + want: []string{"This is line number one", "This is line number two"}, + }, + { + name: "newline with surrounding spaces", + in: " This is the first line \n This is the second line ", + want: []string{"This is the first line", "This is the second line"}, + }, + { + name: "trailing punctuation consumed", + in: "Please wait a moment... What on earth?! That is perfectly fine.", + want: []string{"Please wait a moment...", "What on earth?!", "That is perfectly fine."}, + }, + { + name: "short leading fragment merges with next", + in: "Hi. This is a longer sentence.", + want: []string{"Hi. This is a longer sentence."}, + }, + { + name: "consecutive short fragments keep merging", + in: "A. B. C. This is the real sentence.", + want: []string{"A. B. C. This is the real sentence."}, + }, + { + name: "short trailing fragment merges back", + in: "This sentence is long enough. End.", + want: []string{"This sentence is long enough. End."}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := SplitSentences(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("SplitSentences(%q) = %#v, want %#v", tc.in, got, tc.want) + } + }) + } +} diff --git a/pkg/audio/tts/README.md b/pkg/audio/tts/README.md new file mode 100644 index 000000000..ab8491da6 --- /dev/null +++ b/pkg/audio/tts/README.md @@ -0,0 +1,137 @@ +# TTS (Text-to-Speech) + +This package handles speech synthesis for PicoClaw. + +If you are new to TTS setup, the simplest workflow is: + +1. Add a TTS-capable entry to `model_list`. +2. Point `voice.tts_model_name` at that entry. +3. Put the API key in `.security.yml`. + +## Quick Recommendation + +For most users, these are the best starting points: + +| Provider | Why start here | +| --- | --- | +| [OpenAI](https://platform.openai.com/docs/guides/text-to-speech) | Best-supported path in PicoClaw today. The current TTS implementation is built around the OpenAI-compatible `/audio/speech` API shape, and OpenAI is the safest default. | +| [Xiaomi MiMo](https://platform.xiaomimimo.com) | A good second option if you want an OpenAI-compatible provider endpoint and are already using MiMo models in the rest of your stack. | + +## How TTS Configuration Works + +PicoClaw does not keep TTS API keys inside `voice`. + +Instead: + +- `voice.tts_model_name` selects a named entry from `model_list`. +- That `model_list` entry provides the provider, model ID, API base, and proxy settings. +- `.security.yml` stores the API key for the same named model entry. + +This is the recommended and supported configuration pattern. + +## Recommended Setup + +### Option A: OpenAI + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "openai-tts" + }, + "model_list": [ + { + "model_name": "openai-tts", + "model": "openai/tts-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-tts: + api_keys: + - "sk-openai-your-key" +``` + +### Option B: Xiaomi MiMo + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "mimo-tts" + }, + "model_list": [ + { + "model_name": "mimo-tts", + "model": "mimo/mimo-v2-tts" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + mimo-tts: + api_keys: + - "your-mimo-key" +``` + +If you use a custom MiMo endpoint, you can also set `api_base` explicitly. Otherwise PicoClaw will use the provider default. + +## What PicoClaw Sends Today + +The current TTS runtime uses an OpenAI-compatible speech request with these defaults: + +- Endpoint: `/audio/speech` +- Response format: `opus` +- Voice: `alloy` +- Model: taken from the selected `model_list` entry + +That means: + +- `openai/tts-1` works naturally. +- Other OpenAI-compatible providers can work if they accept the same request format. +- PicoClaw currently does not expose a user-facing config field for changing the TTS voice from `alloy`. + +## How PicoClaw Chooses a TTS Provider + +`DetectTTS` resolves TTS in this order: + +1. **Preferred path**: resolve `voice.tts_model_name` against `model_list`. +2. If a matching model entry exists and has an API key, PicoClaw creates an OpenAI-compatible TTS provider using that model's settings. +3. **Fallback path**: if `voice.tts_model_name` is not set or cannot be resolved, PicoClaw scans `model_list` for the first entry whose model string contains `tts` and has an API key. + +Fallback scanning exists for compatibility. New configs should set `voice.tts_model_name` explicitly. + +## Notes About API Base Handling + +PicoClaw normalizes the configured base URL for TTS: + +- For OpenAI, a base like `https://api.openai.com` or `https://api.openai.com/v1` becomes `https://api.openai.com/v1/audio/speech`. +- For other OpenAI-compatible providers, PicoClaw preserves the configured base path and ensures it ends with `/audio/speech`. +- If `api_base` is omitted, PicoClaw uses the provider default base when the model prefix is known. + +## Common Mistakes + +- Setting `voice.tts_model_name` to a name that does not exist in `model_list`. +- Adding a TTS model but forgetting to put its API key in `.security.yml`. +- Assuming PicoClaw will automatically use provider-specific custom voices. +- Using a provider endpoint that is not compatible with the OpenAI `/audio/speech` request format. + +## Minimal Checklist + +Before testing `send_tts`, make sure: + +- `voice.tts_model_name` matches a `model_list[].model_name`. +- The matching `.security.yml` entry contains a valid API key. +- The chosen provider supports an OpenAI-compatible speech synthesis endpoint. +- Your selected model is actually a TTS-capable model. diff --git a/pkg/audio/tts/README_zh.md b/pkg/audio/tts/README_zh.md new file mode 100644 index 000000000..a48b612a9 --- /dev/null +++ b/pkg/audio/tts/README_zh.md @@ -0,0 +1,137 @@ +# TTS(文本转语音) + +这个目录负责 PicoClaw 的语音合成能力。 + +如果你是第一次配置 TTS,可以参照下面这个流程: + +1. 在 `model_list` 里添加一个支持 TTS 的模型。 +2. 用 `voice.tts_model_name` 指向这个模型。 +3. 在 `.security.yml` 里配置对应的 API Key。 + +## 快速推荐 + +对于大多数用户,建议优先从下面两种开始: + +| 提供商 | 推荐理由 | +| --- | --- | +| [OpenAI](https://platform.openai.com/docs/guides/text-to-speech) | 这是 PicoClaw 当前最稳定、最直接的 TTS 路径。当前实现就是围绕 OpenAI 兼容的 `/audio/speech` 接口格式构建的,所以 OpenAI 是最稳妥的默认选择。 | +| [Xiaomi MiMo](https://platform.xiaomimimo.com) | 由于响应速度和语音音色对于中国用户更友好,MiMo 是一个不错的第二选择。 | + +## TTS 配置是如何工作的 + +PicoClaw 不会把 TTS 的 API Key 放在 `voice` 配置里。 + +推荐方式是: + +- `voice.tts_model_name` 用来选择 `model_list` 里的某个命名模型。 +- 对应的 `model_list` 条目提供真实的 provider、model ID、`api_base` 和代理配置。 +- `.security.yml` 负责保存该模型条目的 API Key。 + +这是当前推荐且受支持的配置方式。 + +## 推荐配置方式 + +### 方案 A:OpenAI + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "openai-tts" + }, + "model_list": [ + { + "model_name": "openai-tts", + "model": "openai/tts-1" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + openai-tts: + api_keys: + - "sk-openai-your-key" +``` + +### 方案 B:Xiaomi MiMo + +`config.json` + +```json +{ + "voice": { + "tts_model_name": "mimo-tts" + }, + "model_list": [ + { + "model_name": "mimo-tts", + "model": "mimo/mimo-v2-tts" + } + ] +} +``` + +`.security.yml` + +```yaml +model_list: + mimo-tts: + api_keys: + - "your-mimo-key" +``` + +如果你使用自定义的 MiMo 接口地址,也可以显式设置 `api_base`。如果不设置,PicoClaw 会自动使用该 provider 的默认地址。 + +## PicoClaw 当前实际发送的 TTS 请求 + +当前 TTS 运行时使用的是 OpenAI 兼容的语音合成请求,并带有以下默认值: + +- Endpoint:`/audio/speech` +- 返回格式:`opus` +- Voice:`alloy` +- Model:来自你所选中的 `model_list` 条目 + +这意味着: + +- `openai/tts-1` 可以自然工作。 +- 其他 OpenAI 兼容 provider 也可能可用,前提是它们接受相同的请求格式。 +- PicoClaw 目前还没有对用户暴露一个配置项来修改 TTS voice,当前固定为 `alloy`。 + +## PicoClaw 如何选择 TTS Provider + +`DetectTTS` 会按下面顺序选择 TTS: + +1. **首选路径**:根据 `voice.tts_model_name` 在 `model_list` 中找到对应模型。 +2. 如果找到了匹配条目,并且它有 API Key,PicoClaw 就会使用这个模型条目的配置创建一个 OpenAI 兼容的 TTS provider。 +3. **回退路径**:如果没有设置 `voice.tts_model_name`,或者该名字无法解析,PicoClaw 会扫描 `model_list`,选中第一个模型字符串里包含 `tts` 且带有 API Key 的条目。 + +回退扫描只是为了兼容旧行为。新配置建议始终显式设置 `voice.tts_model_name`。 + +## 关于 API Base 的处理方式 + +PicoClaw 会对 TTS 的 `api_base` 做规范化处理: + +- 对 OpenAI 来说,像 `https://api.openai.com` 或 `https://api.openai.com/v1` 这样的地址,会自动变成 `https://api.openai.com/v1/audio/speech`。 +- 对其他 OpenAI 兼容 provider,PicoClaw 会尽量保留你提供的基础路径,只确保它最终以 `/audio/speech` 结尾。 +- 如果没有设置 `api_base`,并且模型前缀是已知 provider,PicoClaw 会自动使用该 provider 的默认地址。 + +## 常见错误 + +- `voice.tts_model_name` 指向了一个不存在的 `model_list` 名称。 +- 在 `model_list` 里定义了 TTS 模型,但忘了在 `.security.yml` 中配置对应 API Key。 +- 误以为 PicoClaw 会自动支持 provider 自定义 voice 参数。 +- 使用了不兼容 OpenAI `/audio/speech` 请求格式的接口地址。 + +## 最小检查清单 + +在测试 `send_tts` 之前,请确认: + +- `voice.tts_model_name` 能正确匹配某个 `model_list[].model_name`。 +- `.security.yml` 中对应条目已经配置了有效 API Key。 +- 你所选的 provider 支持 OpenAI 兼容的语音合成接口。 +- 你选择的模型本身确实支持 TTS。 diff --git a/pkg/audio/tts/mimo_tts.go b/pkg/audio/tts/mimo_tts.go new file mode 100644 index 000000000..a8aee6b8c --- /dev/null +++ b/pkg/audio/tts/mimo_tts.go @@ -0,0 +1,162 @@ +package tts + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +type MimoTTSProvider struct { + apiKey string + apiBase string + voice string + format string + model string + httpClient *http.Client +} + +func NewMimoTTSProvider(apiKey string, apiBase string, model string, proxyURL string) *MimoTTSProvider { + if apiBase == "" { + apiBase = "https://api.xiaomimimo.com/v1/chat/completions" + } else { + if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" { + path := u.Path + if u.Host == "api.xiaomimimo.com" { + if path == "" || path == "/" || path == "/v1" || path == "/v1/" { + path = "/v1/chat/completions" + } else { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if !strings.HasPrefix(path, "/v1/") { + path = "/v1" + strings.TrimSuffix(path, "/") + } + if !strings.HasSuffix(path, "/chat/completions") { + path = strings.TrimSuffix(path, "/") + "/chat/completions" + } + } + } else { + if !strings.HasSuffix(path, "/chat/completions") { + path = strings.TrimSuffix(path, "/") + "/chat/completions" + } + } + u.Path = path + apiBase = u.String() + } else { + if apiBase == "https://api.xiaomimimo.com/v1" { + apiBase = "https://api.xiaomimimo.com/v1/chat/completions" + } else if !strings.HasSuffix(apiBase, "/chat/completions") { + apiBase = strings.TrimSuffix(apiBase, "/") + "/chat/completions" + } + } + } + + model = strings.TrimSpace(model) + if model == "" { + model = "mimo-v2-tts" + } + + client := &http.Client{Timeout: 60 * time.Second} + if proxyURL != "" { + if pURL, err := url.Parse(proxyURL); err == nil { + client.Transport = &http.Transport{Proxy: http.ProxyURL(pURL)} + } else { + logger.WarnF( + "NewMimoTTSProvider: invalid proxy URL; proceeding without proxy", + map[string]any{"proxyURL": proxyURL, "error": err}, + ) + } + } + + return &MimoTTSProvider{ + apiKey: apiKey, + apiBase: apiBase, + voice: "default_zh", // mimo_default now seems to be an alias for default_en, which is not working for Chinese TTS. default_zh seems to work fine with both English and Chinese, and is likely the intended default for TTS. + format: "mp3", + model: model, + httpClient: client, + } +} + +func (t *MimoTTSProvider) Name() string { + return "mimo-tts" +} + +func (t *MimoTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) { + logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text), "provider": t.Name()}) + + reqBody := map[string]any{ + "model": t.model, + "messages": []map[string]string{ + {"role": "assistant", "content": text}, + }, + "audio": map[string]string{ + "format": t.format, + "voice": t.voice, + }, + "stream": false, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Api-Key", t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var payload struct { + Choices []struct { + Message struct { + Audio struct { + Data string `json:"data"` + } `json:"audio"` + } `json:"message"` + } `json:"choices"` + } + + err = json.Unmarshal(body, &payload) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(payload.Choices) == 0 || payload.Choices[0].Message.Audio.Data == "" { + return nil, fmt.Errorf("invalid TTS response: missing audio data") + } + + audioBytes, err := base64.StdEncoding.DecodeString(payload.Choices[0].Message.Audio.Data) + if err != nil { + return nil, fmt.Errorf("failed to decode audio data: %w", err) + } + + return io.NopCloser(bytes.NewReader(audioBytes)), nil +} diff --git a/pkg/audio/tts/openai_tts.go b/pkg/audio/tts/openai_tts.go new file mode 100644 index 000000000..786414873 --- /dev/null +++ b/pkg/audio/tts/openai_tts.go @@ -0,0 +1,126 @@ +package tts + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers/common" +) + +type OpenAITTSProvider struct { + apiKey string + apiBase string + voice string + model string + httpClient *http.Client +} + +func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider { + // Normalize apiBase to avoid malformed endpoints like + // "https://api.openai.com/audio/speech" when "/v1" is required. + if apiBase == "" { + apiBase = "https://api.openai.com/v1/audio/speech" + } else { + if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" { + path := u.Path + if u.Host == "api.openai.com" { + // For the official OpenAI host, ensure exactly one /v1 prefix and + // that the path ends with /audio/speech. + if path == "" || path == "/" || path == "/v1" { + path = "/v1/audio/speech" + } else { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if !strings.HasPrefix(path, "/v1/") { + path = "/v1" + strings.TrimSuffix(path, "/") + } + if !strings.HasSuffix(path, "/audio/speech") { + path = strings.TrimSuffix(path, "/") + "/audio/speech" + } + } + } else { + // For non-OpenAI hosts (e.g., proxies), preserve the existing base + // path and only ensure it ends with /audio/speech. + if !strings.HasSuffix(path, "/audio/speech") { + path = strings.TrimSuffix(path, "/") + "/audio/speech" + } + } + u.Path = path + apiBase = u.String() + } else { + // Fallback to the previous string-based behavior if parsing fails. + if apiBase == "https://api.openai.com/v1" { + apiBase = "https://api.openai.com/v1/audio/speech" + } else if !strings.HasSuffix(apiBase, "/audio/speech") { + // Just in case they provide openrouter base or standard base + apiBase = strings.TrimSuffix(apiBase, "/") + "/audio/speech" + } + } + } + + client := common.NewHTTPClient(proxyURL) + client.Timeout = 60 * time.Second + + model = strings.TrimSpace(model) + if model == "" { + model = "tts-1" + } + + return &OpenAITTSProvider{ + apiKey: apiKey, + apiBase: apiBase, + voice: "alloy", + model: model, + httpClient: client, + } +} + +func (t *OpenAITTSProvider) Name() string { + return "openai-tts" +} + +func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) { + logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)}) + + reqBody := map[string]any{ + "model": t.model, + "input": text, + "voice": t.voice, + "response_format": "opus", + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + return resp.Body, nil +} diff --git a/pkg/audio/tts/tts.go b/pkg/audio/tts/tts.go new file mode 100644 index 000000000..99a9ef203 --- /dev/null +++ b/pkg/audio/tts/tts.go @@ -0,0 +1,151 @@ +package tts + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type TTSProvider interface { + Name() string + Synthesize(ctx context.Context, text string) (io.ReadCloser, error) +} + +func providerFromModelConfig(mc *config.ModelConfig) TTSProvider { + if mc == nil || mc.APIKey() == "" { + return nil + } + + protocol, modelID := providers.ExtractProtocol(mc.Model) + if modelID == "" { + modelID = strings.TrimSpace(mc.Model) + } + + switch protocol { + case "mimo": + return NewMimoTTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), modelID, mc.Proxy) + default: + return NewOpenAITTSProvider(mc.APIKey(), providers.ResolveAPIBase(mc), mc.Proxy, modelID) + } +} + +func DetectTTS(cfg *config.Config) TTSProvider { + if cfg == nil { + return nil + } + + if modelName := strings.TrimSpace(cfg.Voice.TTSModelName); modelName != "" { + if mc, err := cfg.GetModelConfig(modelName); err == nil { + if provider := providerFromModelConfig(mc); provider != nil { + return provider + } + } + } + + for _, mc := range cfg.ModelList { + if strings.Contains(strings.ToLower(mc.Model), "tts") && mc.APIKey() != "" { + if provider := providerFromModelConfig(mc); provider != nil { + return provider + } + } + } + return nil +} + +// SynthesizeAndStore synthesizes text to speech and registers it in the media store, returning the media reference. +func SynthesizeAndStore( + ctx context.Context, + provider TTSProvider, + store media.MediaStore, + text string, + filename string, + channel string, + chatID string, +) (string, error) { + if provider == nil { + return "", fmt.Errorf("tts provider is not configured") + } + if store == nil { + return "", fmt.Errorf("media store not configured") + } + if channel == "" || chatID == "" { + return "", fmt.Errorf("no target channel/chat available") + } + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("text is required") + } + + stream, err := provider.Synthesize(ctx, text) + if err != nil { + return "", fmt.Errorf("tts synthesize failed: %w", err) + } + defer stream.Close() + + err = os.MkdirAll(media.TempDir(), 0o700) + if err != nil { + return "", fmt.Errorf("failed to create media temp dir: %w", err) + } + + fileExt := ".ogg" + contentType := "audio/ogg" + if provider.Name() == "mimo-tts" { + fileExt = ".mp3" + contentType = "audio/mpeg" + } + + file, err := os.CreateTemp(media.TempDir(), "tts-*"+fileExt) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(file.Name()) + } + }() + + _, err = io.Copy(file, stream) + if err != nil { + file.Close() + return "", fmt.Errorf("failed to write tts audio: %w", err) + } + + err = file.Close() + if err != nil { + return "", fmt.Errorf("failed to close tts audio file: %w", err) + } + + filename = strings.TrimSpace(filename) + if filename == "" { + filename = fmt.Sprintf("tts-%d%s", time.Now().Unix(), fileExt) + } + + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + filename += fileExt + } else if ext != fileExt { + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + fileExt + } + + scope := fmt.Sprintf("tool:send_tts:%s:%s:%d", channel, chatID, time.Now().UnixNano()) + ref, err := store.Store(file.Name(), media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "tool:send_tts", + }, scope) + if err != nil { + return "", fmt.Errorf("failed to register audio: %w", err) + } + removeTemp = false + + return ref, nil +} diff --git a/pkg/audio/tts/tts_test.go b/pkg/audio/tts/tts_test.go new file mode 100644 index 000000000..053aa7220 --- /dev/null +++ b/pkg/audio/tts/tts_test.go @@ -0,0 +1,247 @@ +package tts + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +func TestNewOpenAITTSProvider_APIBaseNormalization(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + expect string + }{ + { + name: "empty base", + input: "", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "official host no path", + input: "https://api.openai.com", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "official host v1", + input: "https://api.openai.com/v1", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "official host v1 slash", + input: "https://api.openai.com/v1/", + expect: "https://api.openai.com/v1/audio/speech", + }, + { + name: "non-openai host preserves base path", + input: "https://proxy.example.com/base", + expect: "https://proxy.example.com/base/audio/speech", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + provider := NewOpenAITTSProvider("key", tc.input, "", "") + if provider.apiBase != tc.expect { + t.Fatalf("apiBase mismatch: got %q, want %q", provider.apiBase, tc.expect) + } + }) + } +} + +func TestOpenAITTSProvider_SynthesizeSuccess(t *testing.T) { + t.Parallel() + + var gotPath string + var gotAuth string + var gotContentType string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotContentType = r.Header.Get("Content-Type") + + bodyBytes, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + _ = json.Unmarshal(bodyBytes, &gotBody) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("audio-bytes")) + })) + defer server.Close() + + provider := NewOpenAITTSProvider("k123", server.URL, "", "") + stream, err := provider.Synthesize(context.Background(), "hello") + if err != nil { + t.Fatalf("Synthesize failed: %v", err) + } + defer stream.Close() + + data, err := io.ReadAll(stream) + if err != nil { + t.Fatalf("read stream failed: %v", err) + } + + if gotPath != "/audio/speech" { + t.Fatalf("request path mismatch: got %q", gotPath) + } + if gotAuth != "Bearer k123" { + t.Fatalf("authorization mismatch: got %q", gotAuth) + } + if gotContentType != "application/json" { + t.Fatalf("content-type mismatch: got %q", gotContentType) + } + if gotBody["model"] != "tts-1" || gotBody["voice"] != "alloy" || gotBody["response_format"] != "opus" || + gotBody["input"] != "hello" { + bodyJSON, _ := json.Marshal(gotBody) + t.Fatalf("request body mismatch: %s", string(bodyJSON)) + } + if string(data) != "audio-bytes" { + t.Fatalf("response body mismatch: got %q", string(data)) + } +} + +func TestOpenAITTSProvider_SynthesizeNon200(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("nope")) + })) + defer server.Close() + + provider := NewOpenAITTSProvider("k123", server.URL, "", "") + _, err := provider.Synthesize(context.Background(), "hello") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "API error (status 500): nope") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewOpenAITTSProvider_UsesConfiguredModel(t *testing.T) { + t.Parallel() + + provider := NewOpenAITTSProvider("key", "https://api.xiaomimimo.com/v1", "", "mimo-v2-tts") + if provider.model != "mimo-v2-tts" { + t.Fatalf("model mismatch: got %q, want %q", provider.model, "mimo-v2-tts") + } + if provider.apiBase != "https://api.xiaomimimo.com/v1/audio/speech" { + t.Fatalf("apiBase mismatch: got %q", provider.apiBase) + } +} + +func TestDetectTTS_UsesMimoProviderForMimoModels(t *testing.T) { + t.Parallel() + + provider := DetectTTS(&config.Config{ + Voice: config.VoiceConfig{TTSModelName: "mimo-tts"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "mimo-tts", + Model: "mimo/mimo-v2-tts", + APIKeys: config.SimpleSecureStrings("sk-mimo"), + }, + }, + }) + + ttsProvider, ok := provider.(*MimoTTSProvider) + if !ok { + t.Fatalf("DetectTTS() type = %T, want *MimoTTSProvider", provider) + } + if ttsProvider.model != "mimo-v2-tts" { + t.Fatalf("model mismatch: got %q, want %q", ttsProvider.model, "mimo-v2-tts") + } + if ttsProvider.apiBase != "https://api.xiaomimimo.com/v1/chat/completions" { + t.Fatalf("apiBase mismatch: got %q", ttsProvider.apiBase) + } +} + +type stubTTSProvider struct { + name string +} + +func (s stubTTSProvider) Name() string { + return s.name +} + +func (s stubTTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("audio")), nil +} + +func TestSynthesizeAndStore_UsesOggMetadataByDefault(t *testing.T) { + t.Parallel() + + store := media.NewFileMediaStore() + ref, err := SynthesizeAndStore( + context.Background(), + stubTTSProvider{name: "openai-tts"}, + store, + "hello", + "", + "discord", + "chat123", + ) + if err != nil { + t.Fatalf("SynthesizeAndStore failed: %v", err) + } + + path, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if meta.ContentType != "audio/ogg" { + t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/ogg") + } + if filepath.Ext(path) != ".ogg" { + t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".ogg") + } + if filepath.Ext(meta.Filename) != ".ogg" { + t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".ogg") + } +} + +func TestSynthesizeAndStore_UsesMp3MetadataForMimo(t *testing.T) { + t.Parallel() + + store := media.NewFileMediaStore() + ref, err := SynthesizeAndStore( + context.Background(), + stubTTSProvider{name: "mimo-tts"}, + store, + "hello", + "", + "discord", + "chat123", + ) + if err != nil { + t.Fatalf("SynthesizeAndStore failed: %v", err) + } + + path, meta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if meta.ContentType != "audio/mpeg" { + t.Fatalf("ContentType = %q, want %q", meta.ContentType, "audio/mpeg") + } + if filepath.Ext(path) != ".mp3" { + t.Fatalf("stored file extension = %q, want %q", filepath.Ext(path), ".mp3") + } + if filepath.Ext(meta.Filename) != ".mp3" { + t.Fatalf("filename extension = %q, want %q", filepath.Ext(meta.Filename), ".mp3") + } +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 37fcb74c5..a9c74ef90 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -34,6 +34,8 @@ type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage outboundMedia chan OutboundMediaMessage + audioChunks chan AudioChunk + voiceControls chan VoiceControl closeOnce sync.Once done chan struct{} @@ -47,6 +49,8 @@ func NewMessageBus() *MessageBus { inbound: make(chan InboundMessage, defaultBusBufferSize), outbound: make(chan OutboundMessage, defaultBusBufferSize), outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize), + audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer + voiceControls: make(chan VoiceControl, defaultBusBufferSize), done: make(chan struct{}), } } @@ -103,6 +107,22 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { return mb.outboundMedia } +func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error { + return publish(ctx, mb, mb.audioChunks, chunk) +} + +func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk { + return mb.audioChunks +} + +func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error { + return publish(ctx, mb, mb.voiceControls, ctrl) +} + +func (mb *MessageBus) VoiceControlsChan() <-chan VoiceControl { + return mb.voiceControls +} + // SetStreamDelegate registers a StreamDelegate (typically the channel Manager). func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) { mb.streamDelegate.Store(d) @@ -132,6 +152,8 @@ func (mb *MessageBus) Close() { close(mb.inbound) close(mb.outbound) close(mb.outboundMedia) + close(mb.audioChunks) + close(mb.voiceControls) // clean up any remaining messages in channels drained := 0 @@ -144,6 +166,12 @@ func (mb *MessageBus) Close() { for range mb.outboundMedia { drained++ } + for range mb.audioChunks { + drained++ + } + for range mb.voiceControls { + drained++ + } if drained > 0 { logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 12da3f1dd..27cf61b5f 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -30,10 +30,11 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } // MediaPart describes a single media attachment to send. @@ -51,3 +52,25 @@ type OutboundMediaMessage struct { ChatID string `json:"chat_id"` Parts []MediaPart `json:"parts"` } + +// AudioChunk represents a chunk of streaming voice data. +type AudioChunk struct { + SessionID string `json:"session_id"` + SpeakerID string `json:"speaker_id"` // User ID or SSRC + ChatID string `json:"chat_id"` // Where to respond + Channel string `json:"channel"` // Source channel type (e.g. "discord") + Sequence uint64 `json:"sequence"` + Timestamp uint32 `json:"timestamp"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + Format string `json:"format"` // "opus", "pcm", etc + Data []byte `json:"data"` +} + +// VoiceControl represents state or commands for voice sessions. +type VoiceControl struct { + SessionID string `json:"session_id"` + ChatID string `json:"chat_id"` + Type string `json:"type"` // "state", "command" + Action string `json:"action"` // "idle", "listening", "start", "stop", "leave" +} diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index b3070a822..01b1b4053 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -3,6 +3,7 @@ package discord import ( "context" "fmt" + "io" "net/http" "net/url" "os" @@ -14,6 +15,8 @@ import ( "github.com/bwmarrin/discordgo" "github.com/gorilla/websocket" + "github.com/sipeed/picoclaw/pkg/audio" + "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -42,6 +45,15 @@ type DiscordChannel struct { typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal botUserID string // stored for mention checking + bus *bus.MessageBus + tts tts.TTSProvider + voiceMu sync.RWMutex + voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID + + // TTS interruption: cancel active playback when user speaks + ttsMu sync.Mutex + cancelTTS context.CancelFunc + ttsPlayID uint64 } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { @@ -73,6 +85,8 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC config: cfg, ctx: context.Background(), typingStop: make(map[string]chan struct{}), + bus: bus, + voiceSSRC: make(map[string]map[uint32]string), }, nil } @@ -90,6 +104,8 @@ func (c *DiscordChannel) Start(ctx context.Context) error { c.session.AddHandler(c.handleMessage) + go c.listenVoiceControl(c.ctx) + if err := c.session.Open(); err != nil { return fmt.Errorf("failed to open discord session: %w", err) } @@ -142,6 +158,25 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s return nil, nil } + if c.tts != nil { + if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { + if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { + // Cancel any previous TTS playback + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + c.ttsMu.Unlock() + + go c.playTTS(ttsCtx, vc, msg.Content, playID) + } + } + } + msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) if err != nil { return nil, err @@ -359,6 +394,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } + if c.handleVoiceCommand(s, m) { + return + } + content := m.Content // In guild (group) channels, apply unified group trigger filtering @@ -630,3 +669,134 @@ func (c *DiscordChannel) stripBotMention(text string) string { text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") return strings.TrimSpace(text) } + +func (c *DiscordChannel) listenVoiceControl(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case ctrl, ok := <-c.bus.VoiceControlsChan(): + if !ok { + return + } + if ctrl.Type == "command" && ctrl.Action == "leave" { + if strings.HasPrefix(ctrl.SessionID, "discord_vc_") { + guildID := strings.TrimPrefix(ctrl.SessionID, "discord_vc_") + vc, exists := c.session.VoiceConnections[guildID] + if exists && vc != nil { + vc.Disconnect(ctx) + } + } + } + } + } +} + +func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string, playID uint64) { + // Capture the cancel func associated with this playback (if any). + // Clear cancelTTS when playback finishes (normal or interrupted), + // but only if it still refers to this playback's cancel func. + defer func() { + c.ttsMu.Lock() + if c.ttsPlayID == playID { + c.cancelTTS = nil + } + c.ttsMu.Unlock() + }() + + sentences := audio.SplitSentences(text) + if len(sentences) == 0 { + return + } + + logger.InfoCF("discord", "Starting streamed TTS", map[string]any{"sentences": len(sentences)}) + + // Pipeline: prefetch next sentence's audio while playing current + type ttResult struct { + stream io.ReadCloser + err error + } + + var prefetch chan ttResult + + // Ensure any in-flight prefetch is drained on exit to prevent stream leaks, + // but avoid blocking indefinitely if the prefetch goroutine is stuck or never sends. + defer func() { + if prefetch != nil { + select { + case result := <-prefetch: + if result.stream != nil { + result.stream.Close() + } + case <-time.After(100 * time.Millisecond): + // Timed out waiting for a prefetched result; avoid blocking on exit. + } + } + }() + + for i, sentence := range sentences { + // Check for cancellation (interruption) + select { + case <-ctx.Done(): + logger.InfoCF("discord", "TTS interrupted", map[string]any{"at_sentence": i}) + return + default: + } + + // Start prefetching the NEXT sentence while we process the current one + var nextPrefetch chan ttResult + if i+1 < len(sentences) { + nextPrefetch = make(chan ttResult, 1) + nextSentence := sentences[i+1] + go func() { + s, e := c.tts.Synthesize(ctx, nextSentence) + nextPrefetch <- ttResult{s, e} + }() + } + + // Get the current sentence's audio + var stream io.ReadCloser + var err error + + if prefetch != nil { + // Use prefetched result from previous iteration, but be responsive to cancellation. + var result ttResult + select { + case result = <-prefetch: + stream, err = result.stream, result.err + case <-ctx.Done(): + // Context canceled while waiting for prefetched audio; abort playback. + logger.InfoCF( + "discord", + "TTS interrupted while waiting for prefetched audio", + map[string]any{"at_sentence": i}, + ) + return + } + } else { + // First sentence: synthesize directly + stream, err = c.tts.Synthesize(ctx, sentence) + } + + if err != nil { + if stream != nil { + stream.Close() + } + logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error(), "sentence": i}) + prefetch = nextPrefetch + continue + } + + if err := streamOggOpusToDiscord(ctx, vc, stream); err != nil { + logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error(), "sentence": i}) + } + stream.Close() + + prefetch = nextPrefetch + } +} + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *DiscordChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go index 15a539804..8381dc9e9 100644 --- a/pkg/channels/discord/init.go +++ b/pkg/channels/discord/init.go @@ -1,6 +1,7 @@ package discord import ( + "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -8,6 +9,10 @@ import ( func init() { channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewDiscordChannel(cfg.Channels.Discord, b) + ch, err := NewDiscordChannel(cfg.Channels.Discord, b) + if err == nil { + ch.tts = tts.DetectTTS(cfg) + } + return ch, err }) } diff --git a/pkg/channels/discord/voice.go b/pkg/channels/discord/voice.go new file mode 100644 index 000000000..5b686b141 --- /dev/null +++ b/pkg/channels/discord/voice.go @@ -0,0 +1,313 @@ +package discord + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (c *DiscordChannel) setVoiceUserID(guildID string, ssrc uint32, userID string) { + if userID == "" { + return + } + + c.voiceMu.Lock() + defer c.voiceMu.Unlock() + + ssrcMap, ok := c.voiceSSRC[guildID] + if !ok { + ssrcMap = make(map[uint32]string) + c.voiceSSRC[guildID] = ssrcMap + } + ssrcMap[ssrc] = userID +} + +func (c *DiscordChannel) voiceUserID(guildID string, ssrc uint32) string { + c.voiceMu.RLock() + defer c.voiceMu.RUnlock() + + ssrcMap, ok := c.voiceSSRC[guildID] + if !ok { + return "" + } + return ssrcMap[ssrc] +} + +func (c *DiscordChannel) handleVoiceCommand(s *discordgo.Session, m *discordgo.MessageCreate) bool { + if m.Content == "!vc join" { + vs, err := s.State.VoiceState(m.GuildID, m.Author.ID) + if err != nil || vs == nil { + if _, sendErr := s.ChannelMessageSend( + m.ChannelID, + "You need to be in a voice channel first!", + ); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice channel requirement message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + return true + } + + logger.InfoCF("discord", "Joining voice channel", map[string]any{"channel": vs.ChannelID}) + vc, err := s.ChannelVoiceJoin(c.ctx, m.GuildID, vs.ChannelID, false, false) + if err != nil { + if _, sendErr := s.ChannelMessageSend( + m.ChannelID, + fmt.Sprintf("Failed to join voice channel: %v", err), + ); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice join error message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + return true + } + + go c.receiveVoice(vc, m.GuildID, m.ChannelID) + if _, sendErr := s.ChannelMessageSend( + m.ChannelID, + "Joined Voice Channel! Listening for audio...", + ); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice join success message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + return true + } else if m.Content == "!vc leave" { + vc, exists := s.VoiceConnections[m.GuildID] + if exists && vc != nil { + if err := vc.Disconnect(c.ctx); err != nil { + logger.InfoCF("discord", "Failed to disconnect from voice channel", map[string]any{ + "guild": m.GuildID, + "error": err, + }) + } + if _, sendErr := s.ChannelMessageSend(m.ChannelID, "Left Voice Channel."); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice leave success message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + } else { + if _, sendErr := s.ChannelMessageSend(m.ChannelID, "Not in a voice channel."); sendErr != nil { + logger.InfoCF("discord", "Failed to send voice not-in-channel message", map[string]any{ + "channel": m.ChannelID, + "error": sendErr, + }) + } + } + return true + } + return false +} + +func VoiceReceiveActive(vc *discordgo.VoiceConnection) bool { + return vc != nil && vc.OpusRecv != nil +} + +func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, r io.Reader) (retErr error) { + // Recover from panic if vc.OpusSend is closed mid-send (e.g. on disconnect) + defer func() { + if rec := recover(); rec != nil { + retErr = fmt.Errorf("voice connection closed during playback") + } + }() + + // Wait for the speaking transition to register + vc.Speaking(true) + defer vc.Speaking(false) + + return audio.DecodeOggOpus(r, func(frame []byte) error { + select { + case <-ctx.Done(): + return ctx.Err() + case vc.OpusSend <- frame: + return nil + } + }) +} + +func (c *DiscordChannel) receiveVoice(vc *discordgo.VoiceConnection, guildID string, chatID string) { + logger.InfoCF("discord", "Started listening for voice", map[string]any{"guild": guildID}) + + vc.AddHandler(func(_ *discordgo.VoiceConnection, vs *discordgo.VoiceSpeakingUpdate) { + if vs == nil { + return + } + c.setVoiceUserID(guildID, uint32(vs.SSRC), vs.UserID) + }) + + defer func() { + c.voiceMu.Lock() + delete(c.voiceSSRC, guildID) + c.voiceMu.Unlock() + }() + + go func(ctx context.Context, vc *discordgo.VoiceConnection) { + // Recover from potential panics if OpusSend is closed mid-send. + defer func() { + if rec := recover(); rec != nil { + logger.WarnCF("discord", "Recovered from panic while sending wake-up frames", map[string]any{ + "error": rec, + "guild": guildID, + }) + } + }() + + // If the voice connection or OpusSend are not available, nothing to do. + if vc == nil || vc.OpusSend == nil { + return + } + + time.Sleep(250 * time.Millisecond) // Wait a bit for connection to settle + + // Abort if the context has already been canceled. + select { + case <-ctx.Done(): + return + default: + } + + vc.Speaking(true) + defer vc.Speaking(false) + + silenceFrame := []byte{0xF8, 0xFF, 0xFE} + for i := 0; i < 5; i++ { + select { + case <-ctx.Done(): + return + case vc.OpusSend <- silenceFrame: + } + time.Sleep(20 * time.Millisecond) + } + + logger.DebugCF("discord", "Sent wake-up silence frames", map[string]any{"guild": guildID}) + }(c.ctx, vc) + sessionID := fmt.Sprintf("discord_vc_%s", guildID) + + c.bus.PublishVoiceControl(c.ctx, bus.VoiceControl{ + SessionID: sessionID, + Type: "state", + Action: "listening", + }) + + var sequence uint64 = 0 + var interruptCount int + var lastInterruptAt time.Time + + for { + select { + case <-c.ctx.Done(): + return + case p, ok := <-vc.OpusRecv: + if !ok { + logger.InfoCF("discord", "Voice channel closed", map[string]any{"guild": guildID}) + // Cancel any TTS that may still be playing + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + c.cancelTTS = nil + } + c.ttsMu.Unlock() + return + } + + if p == nil { + logger.DebugCF("discord", "Received nil Opus packet", nil) + continue + } + + if len(p.Opus) == 0 { + logger.DebugCF("discord", "Received empty Opus packet", map[string]any{ + "seq": p.Sequence, + "ssrc": p.SSRC, + }) + continue + } + + logger.DebugCF("discord", "Received Opus packet", map[string]any{ + "seq": p.Sequence, + "len": len(p.Opus), + "ssrc": p.SSRC, + }) + // Interruption detection: if user sends voice while TTS is playing, + // cancel TTS after a short debounce (3 packets in 200ms) + now := time.Now() + if now.Sub(lastInterruptAt) > 500*time.Millisecond { + interruptCount = 0 + } + interruptCount++ + lastInterruptAt = now + + if interruptCount >= 3 { + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + c.cancelTTS = nil + logger.InfoCF("discord", "TTS interrupted by user voice", nil) + } + c.ttsMu.Unlock() + interruptCount = 0 + } + + userID := c.voiceUserID(guildID, p.SSRC) + if userID == "" { + logger.DebugCF("discord", "Dropping voice packet without user mapping", map[string]any{ + "ssrc": p.SSRC, + "guild": guildID, + }) + continue + } + + sender := bus.SenderInfo{ + Platform: "discord", + PlatformID: userID, + CanonicalID: identity.BuildCanonicalID("discord", userID), + } + if !c.IsAllowedSender(sender) { + logger.DebugCF("discord", "Voice packet rejected by allowlist", map[string]any{ + "user_id": userID, + "guild": guildID, + }) + continue + } + + sequence++ + + chunk := bus.AudioChunk{ + SessionID: sessionID, + SpeakerID: userID, + ChatID: chatID, + Channel: "discord", + Sequence: sequence, + Timestamp: p.Timestamp, + SampleRate: 48000, + Channels: 2, + Format: "opus", + Data: p.Opus, + } + + ctx, cancel := context.WithTimeout(c.ctx, 100*time.Millisecond) + err := c.bus.PublishAudioChunk(ctx, chunk) + cancel() + if err != nil { + logger.ErrorCF("discord", "Failed to publish audio chunk", map[string]any{ + "guild": guildID, + "sessionID": sessionID, + "sequence": sequence, + "error": err.Error(), + }) + } + } + } +} diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index 4952394b7..81238460a 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -6,6 +6,8 @@ import ( "strings" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) // mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions. @@ -145,3 +147,8 @@ func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) { } } } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *FeishuChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index e29896389..230983935 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -684,3 +684,8 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string { }, }) } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *LINEChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 96db964cf..5e975b4f0 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -1300,3 +1300,8 @@ func stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp. cleaned = strings.TrimLeft(cleaned, ",:; ") return strings.TrimSpace(cleaned) } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *MatrixChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index a9b95c20f..0c59965c1 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -1104,3 +1104,8 @@ func truncate(s string, n int) string { } return string(runes[:n]) + "..." } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *OneBotChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 3a8cf9652..f2b70aec9 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -1002,3 +1002,8 @@ func sanitizeURLs(text string) string { return scheme + domain + path }) } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *QQChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 831eb43cc..ccb394a57 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -1133,3 +1133,8 @@ func cryptoRandInt() int { _, _ = rand.Read(b[:]) return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/channels/voice_capabilities.go b/pkg/channels/voice_capabilities.go new file mode 100644 index 000000000..34fd24269 --- /dev/null +++ b/pkg/channels/voice_capabilities.go @@ -0,0 +1,58 @@ +package channels + +// VoiceCapabilities describes whether ASR (speech-to-text) and TTS (text-to-speech) +// are available for a channel under the current configuration. +type VoiceCapabilities struct { + ASR bool + TTS bool +} + +// VoiceCapabilityProvider is an optional interface for channels that want to +// explicitly declare their ASR/TTS support. +type VoiceCapabilityProvider interface { + VoiceCapabilities() VoiceCapabilities +} + +// Deprecated: Channels should implement VoiceCapabilityProvider instead. +// To be removed once all existing capable channels conform to the interface. +var asrCapableChannels = map[string]bool{ + "discord": true, + "telegram": true, + "matrix": true, + "qq": true, + "weixin": true, + "line": true, + "feishu": true, + "onebot": true, +} + +// DetectVoiceCapabilities returns ASR/TTS availability for a channel, gated by +// whether providers are configured. +func DetectVoiceCapabilities(channelName string, ch Channel, asrAvailable bool, ttsAvailable bool) VoiceCapabilities { + if ch == nil { + return VoiceCapabilities{} + } + + if vcp, ok := ch.(VoiceCapabilityProvider); ok { + caps := vcp.VoiceCapabilities() + if !asrAvailable { + caps.ASR = false + } + if !ttsAvailable { + caps.TTS = false + } + return caps + } + + caps := VoiceCapabilities{} + if asrAvailable { + caps.ASR = asrCapableChannels[channelName] + } + if ttsAvailable { + if _, ok := ch.(MediaSender); ok { + caps.TTS = true + } + } + + return caps +} diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 0e9010131..a0d0c96b5 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -402,3 +402,8 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, nil } + +// VoiceCapabilities returns the voice capabilities of the channel. +func (c *WeixinChannel) VoiceCapabilities() channels.VoiceCapabilities { + return channels.VoiceCapabilities{ASR: true, TTS: true} +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 397cd4ab8..7a11d1ab7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -558,9 +558,9 @@ type DevicesConfig struct { } type VoiceConfig struct { - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` - EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` - ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"` + ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` + TTSModelName string `json:"tts_model_name,omitempty" env:"PICOCLAW_VOICE_TTS_MODEL_NAME"` + EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` } // ModelConfig represents a model-centric provider configuration. @@ -829,6 +829,7 @@ type ToolsConfig struct { Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"` Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` @@ -1281,6 +1282,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.WebFetch.Enabled case "send_file": return t.SendFile.Enabled + case "send_tts": + return t.SendTTS.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c3845e3e2..6eac5d8b9 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -434,6 +434,9 @@ func DefaultConfig() *Config { SendFile: ToolConfig{ Enabled: true, }, + SendTTS: ToolConfig{ + Enabled: false, + }, MCP: MCPConfig{ ToolConfig: ToolConfig{ Enabled: false, diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 64aed5e8c..b5e8c1f36 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "path/filepath" + "sort" "strings" "sync" "sync/atomic" @@ -13,6 +14,8 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" @@ -41,7 +44,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/voice" ) const ( @@ -61,6 +63,7 @@ type services struct { ChannelManager *channels.Manager DeviceService *devices.Service HealthServer *health.Server + VoiceAgentCancel context.CancelFunc manualReloadChan chan struct{} reloading atomic.Bool authToken string @@ -70,6 +73,27 @@ type startupBlockedProvider struct { reason string } +func logChannelVoiceCapabilities(cm *channels.Manager, asrAvailable bool, ttsAvailable bool) { + if cm == nil { + return + } + + names := cm.GetEnabledChannels() + sort.Strings(names) + for _, name := range names { + ch, ok := cm.GetChannel(name) + if !ok { + continue + } + caps := channels.DetectVoiceCapabilities(name, ch, asrAvailable, ttsAvailable) + logger.InfoCF("voice", "Channel voice capabilities", map[string]any{ + "channel": name, + "asr": caps.ASR, + "tts": caps.TTS, + }) + } +} + func (p *startupBlockedProvider) Chat( _ context.Context, _ []providers.Message, @@ -337,11 +361,14 @@ func setupAndStartServices( agentLoop.SetChannelManager(runningServices.ChannelManager) agentLoop.SetMediaStore(runningServices.MediaStore) - if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { + transcriber := asr.DetectTranscriber(cfg) + if transcriber != nil { agentLoop.SetTranscriber(transcriber) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } + ttsAvailable := tts.DetectTTS(cfg) != nil + enabledChannels := runningServices.ChannelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) @@ -358,6 +385,16 @@ func setupAndStartServices( return nil, fmt.Errorf("error starting channels: %w", err) } + logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable) + + if transcriber != nil { + // Start Voice Agent Orchestrator after channels are ready. + vaCtx, vaCancel := context.WithCancel(context.Background()) + runningServices.VoiceAgentCancel = vaCancel + voiceAgent := asr.NewAgent(msgBus, transcriber) + voiceAgent.Start(vaCtx) + } + fmt.Printf( "✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n", cfg.Gateway.Host, @@ -387,6 +424,9 @@ func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Dura if !isReload && runningServices.ChannelManager != nil { runningServices.ChannelManager.StopAll(shutdownCtx) } + if runningServices.VoiceAgentCancel != nil { + runningServices.VoiceAgentCancel() + } if runningServices.DeviceService != nil { runningServices.DeviceService.Stop() } @@ -563,14 +603,22 @@ func restartServices( fmt.Println(" ✓ Device event service restarted") } - transcriber := voice.DetectTranscriber(cfg) + transcriber := asr.DetectTranscriber(cfg) al.SetTranscriber(transcriber) if transcriber != nil { logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) + + // Start Voice Agent Orchestrator on reload + vaCtx, vaCancel := context.WithCancel(context.Background()) + runningServices.VoiceAgentCancel = vaCancel + voiceAgent := asr.NewAgent(msgBus, transcriber) + voiceAgent.Start(vaCtx) } else { logger.InfoCF("voice", "Transcription disabled", nil) } + ttsAvailable := tts.DetectTTS(cfg) != nil + logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable) // NOTE: PID file is written once at startup and not updated on reload. // Changing the gateway listen address requires a full restart. diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index e956db209..16b2ead10 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -98,6 +98,19 @@ func ExtractProtocol(model string) (protocol, modelID string) { return protocol, modelID } +// ResolveAPIBase returns the configured API base, or the protocol default when +// the model uses an HTTP-based provider family with a known default endpoint. +func ResolveAPIBase(cfg *config.ModelConfig) string { + if cfg == nil { + return "" + } + if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" { + return strings.TrimRight(apiBase, "/") + } + protocol, _ := ExtractProtocol(cfg.Model) + return strings.TrimRight(getDefaultAPIBase(protocol), "/") +} + // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. // Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini), diff --git a/pkg/tools/tts_send.go b/pkg/tools/tts_send.go new file mode 100644 index 000000000..3d569e3f7 --- /dev/null +++ b/pkg/tools/tts_send.go @@ -0,0 +1,82 @@ +package tools + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/media" +) + +type SendTTSTool struct { + provider tts.TTSProvider + mediaStore media.MediaStore +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return &SendTTSTool{ + provider: provider, + mediaStore: store, + } +} + +func (t *SendTTSTool) Name() string { return "send_tts" } + +func (t *SendTTSTool) Description() string { + return "Synthesize speech from text and send it as an audio file to the user." +} + +func (t *SendTTSTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + "description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.", + }, + "filename": map[string]any{ + "type": "string", + "description": "Optional filename for the audio file (e.g., response.ogg).", + }, + }, + "required": []string{"text"}, + } +} + +func (t *SendTTSTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + text, _ := args["text"].(string) + text = strings.TrimSpace(text) + if text == "" { + return ErrorResult("text is required") + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + filename, _ := args["filename"].(string) + + ref, err := tts.SynthesizeAndStore( + ctx, + t.provider, + t.mediaStore, + text, + filename, + channel, + chatID, + ) + if err != nil { + return ErrorResult(err.Error()).WithError(err) + } + + // Return with ForUser set to original text, Media containing the audio ref, + // and mark as ResponseHandled so the audio is sent immediately without LLM intervention. + return &ToolResult{ + ForLLM: "TTS audio sent", + ForUser: text, + Media: []string{ref}, + ResponseHandled: true, + } +} diff --git a/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go deleted file mode 100644 index b42e598f7..000000000 --- a/pkg/voice/groq_transcriber.go +++ /dev/null @@ -1,151 +0,0 @@ -package voice - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type GroqTranscriber struct { - apiKey string - apiBase string - httpClient *http.Client -} - -func NewGroqTranscriber(apiKey string) *GroqTranscriber { - logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) - - apiBase := "https://api.groq.com/openai/v1" - return &GroqTranscriber{ - apiKey: apiKey, - apiBase: apiBase, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, - } -} - -func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) - - audioFile, err := os.Open(audioFilePath) - if err != nil { - logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) - return nil, fmt.Errorf("failed to open audio file: %w", err) - } - defer audioFile.Close() - - fileInfo, err := audioFile.Stat() - if err != nil { - logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) - return nil, fmt.Errorf("failed to get file info: %w", err) - } - - logger.DebugCF("voice", "Audio file details", map[string]any{ - "size_bytes": fileInfo.Size(), - "file_name": filepath.Base(audioFilePath), - }) - - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - - part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) - if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to create form file: %w", err) - } - - copied, err := io.Copy(part, audioFile) - if err != nil { - logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to copy file content: %w", err) - } - - logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) - - if err = writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to write model field: %w", err) - } - - if err = writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to write response_format field: %w", err) - } - - if err = writer.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to close multipart writer: %w", err) - } - - url := t.apiBase + "/audio/transcriptions" - req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) - if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+t.apiKey) - - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ - "url": url, - "request_size_bytes": requestBody.Len(), - "file_size_bytes": fileInfo.Size(), - }) - - resp, err := t.httpClient.Do(req) - if err != nil { - logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "API error", map[string]any{ - "status_code": resp.StatusCode, - "response": string(body), - }) - return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) - } - - logger.DebugCF("voice", "Received response from Groq API", map[string]any{ - "status_code": resp.StatusCode, - "response_size_bytes": len(body), - }) - - var result TranscriptionResponse - if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - logger.InfoCF("voice", "Transcription completed successfully", map[string]any{ - "text_length": len(result.Text), - "language": result.Language, - "duration_seconds": result.Duration, - "transcription_preview": utils.Truncate(result.Text, 50), - }) - - return &result, nil -} - -func (t *GroqTranscriber) Name() string { - return "groq" -} diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go deleted file mode 100644 index fdcaa7580..000000000 --- a/pkg/voice/groq_transcriber_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package voice - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -var _ Transcriber = (*GroqTranscriber)(nil) - -func TestGroqTranscriberName(t *testing.T) { - tr := NewGroqTranscriber("sk-test") - if got := tr.Name(); got != "groq" { - t.Errorf("Name() = %q, want %q", got, "groq") - } -} - -func TestGroqTranscribe(t *testing.T) { - // Write a minimal fake audio file so the transcriber can open and send it. - tmpDir := t.TempDir() - audioPath := filepath.Join(tmpDir, "clip.ogg") - if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { - t.Fatalf("failed to write fake audio file: %v", err) - } - - t.Run("success", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/audio/transcriptions" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "Bearer sk-test" { - t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(TranscriptionResponse{ - Text: "hello world", - Language: "en", - Duration: 1.5, - }) - })) - defer srv.Close() - - tr := NewGroqTranscriber("sk-test") - tr.apiBase = srv.URL - - resp, err := tr.Transcribe(context.Background(), audioPath) - if err != nil { - t.Fatalf("Transcribe() error: %v", err) - } - if resp.Text != "hello world" { - t.Errorf("Text = %q, want %q", resp.Text, "hello world") - } - if resp.Language != "en" { - t.Errorf("Language = %q, want %q", resp.Language, "en") - } - }) - - t.Run("api error", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) - })) - defer srv.Close() - - tr := NewGroqTranscriber("sk-bad") - tr.apiBase = srv.URL - - _, err := tr.Transcribe(context.Background(), audioPath) - if err == nil { - t.Fatal("expected error for non-200 response, got nil") - } - }) - - t.Run("missing file", func(t *testing.T) { - tr := NewGroqTranscriber("sk-test") - _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) - if err == nil { - t.Fatal("expected error for missing file, got nil") - } - }) -} diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go deleted file mode 100644 index f56fdeedd..000000000 --- a/pkg/voice/transcriber.go +++ /dev/null @@ -1,68 +0,0 @@ -package voice - -import ( - "context" - "strings" - - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/providers" -) - -type Transcriber interface { - Name() string - Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) -} - -type TranscriptionResponse struct { - Text string `json:"text"` - Language string `json:"language,omitempty"` - Duration float64 `json:"duration,omitempty"` -} - -func supportsAudioTranscription(model string) bool { - protocol, _ := providers.ExtractProtocol(model) - - switch protocol { - case "openai", "azure", "azure-openai", - "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", - "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", - "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding": - // These protocols all go through the OpenAI-compatible or Azure provider path in - // providers.CreateProviderFromConfig, so they are the only ones that can supply - // the audio media payload shape expected by NewAudioModelTranscriber. - - // TODO: Further restrict this by modelID, since not every model under these - // protocols supports audio transcription. - return true - default: - return false - } -} - -// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or -// nil if no supported transcription provider is configured. -func DetectTranscriber(cfg *config.Config) Transcriber { - if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" { - modelCfg, err := cfg.GetModelConfig(modelName) - if err != nil { - return nil - } - if supportsAudioTranscription(modelCfg.Model) { - return NewAudioModelTranscriber(modelCfg) - } - } - - // ElevenLabs voice config (supports Scribe STT). - if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" { - return NewElevenLabsTranscriber(key) - } - // Fall back to any model-list entry that uses the groq/ protocol. - for _, mc := range cfg.ModelList { - if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" { - return NewGroqTranscriber(mc.APIKey()) - } - } - return nil -} From f327859cceb75b9d1fae2f1d919d4381e7d4f55d Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:15:28 +0800 Subject: [PATCH 63/72] fix(api): enhance model availability probing with backoff and caching mechanisms (#2231) * fix(api): enhance model availability probing with backoff and caching mechanisms * fix(lint): resolve gci and predeclared issues in model probe * fix(api): address copilot review feedback on probe cache key and test stability * fix(api): reduce probe cache key fragmentation --- web/backend/api/model_status.go | 309 ++++++++++++++++++++++++++- web/backend/api/model_status_test.go | 307 ++++++++++++++++++++++++++ web/backend/api/models_test.go | 4 + 3 files changed, 612 insertions(+), 8 deletions(-) diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 160c4d257..98bd501f5 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -1,19 +1,36 @@ package api import ( + "context" "encoding/json" "fmt" + "hash/fnv" "net" "net/http" "net/url" + "strconv" "strings" + "sync" "time" + "golang.org/x/sync/singleflight" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) -const modelProbeTimeout = 800 * time.Millisecond +const ( + modelProbeTimeout = 800 * time.Millisecond + modelProbeSuccessBaseInterval = 2 * time.Second + modelProbeSuccessMaxInterval = 60 * time.Second + modelProbeFailureBaseInterval = 1 * time.Second + modelProbeFailureMaxInterval = 30 * time.Second + modelProbeBackoffMaxShift = 8 + modelProbeCacheMaxEntries = 1024 + modelProbeCacheEntryTTL = 30 * time.Minute + modelProbeCacheTrimToEntries = modelProbeCacheMaxEntries * 8 / 10 + modelProbeTTLGCInterval = 1 * time.Minute +) const ( modelStatusAvailable = "available" @@ -30,8 +47,41 @@ var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel + modelProbeNowFunc = time.Now + modelProbeState = newModelProbeCacheState() ) +type modelProbeCacheState struct { + mu sync.RWMutex + cache map[string]*modelProbeCacheEntry + group singleflight.Group + nextTTLGCAt time.Time +} + +type modelProbeCacheEntry struct { + lastResult bool + hasResult bool + successStreak int + failureStreak int + nextProbeAt time.Time + updatedAt time.Time +} + +func newModelProbeCacheState() *modelProbeCacheState { + return &modelProbeCacheState{cache: map[string]*modelProbeCacheEntry{}} +} + +func resetModelProbeCache() { + modelProbeState.resetForTest() +} + +func (s *modelProbeCacheState) resetForTest() { + s.mu.Lock() + defer s.mu.Unlock() + s.cache = map[string]*modelProbeCacheEntry{} + s.nextTTLGCAt = time.Time{} +} + func hasModelConfiguration(m *config.ModelConfig) bool { authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) apiKey := strings.TrimSpace(m.APIKey()) @@ -93,6 +143,34 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { } func probeLocalModelAvailability(m *config.ModelConfig) bool { + cacheKey := modelProbeCacheKey(m) + return modelProbeState.probe(cacheKey, func() bool { + return runLocalModelProbe(m) + }) +} + +func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) bool { + now := modelProbeNowFunc() + if cachedResult, ok := s.getCachedResult(cacheKey, now); ok { + return cachedResult + } + + v, _, _ := s.group.Do(cacheKey, func() (any, error) { + now = modelProbeNowFunc() + if cachedResult, ok := s.getCachedResult(cacheKey, now); ok { + return cachedResult, nil + } + + result := probeFunc() + s.setCachedResult(cacheKey, result, now) + return result, nil + }) + + result, _ := v.(bool) + return result +} + +func runLocalModelProbe(m *config.ModelConfig) bool { apiBase := modelProbeAPIBase(m) protocol, modelID := splitModel(m.Model) switch protocol { @@ -112,6 +190,195 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool { } } +func modelProbeCacheKey(m *config.ModelConfig) string { + protocol, modelID := splitModel(m.Model) + + apiBaseRaw := modelProbeAPIBase(m) + apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/")) + apiKeyFingerprint := modelProbeAPIKeyFingerprint(m.APIKey()) + + var b strings.Builder + b.Grow(len(protocol) + len(modelID) + len(apiBase) + len(apiKeyFingerprint) + 8) + b.WriteString(protocol) + b.WriteByte('|') + b.WriteString(modelID) + b.WriteByte('|') + b.WriteString(apiBase) + b.WriteByte('|') + b.WriteString(apiKeyFingerprint) + + return b.String() +} + +func modelProbeAPIKeyFingerprint(raw string) string { + apiKey := strings.TrimSpace(raw) + if apiKey == "" { + return "none" + } + + h := fnv.New64a() + _, _ = h.Write([]byte(apiKey)) + return strconv.FormatUint(h.Sum64(), 36) +} + +func (s *modelProbeCacheState) getCachedResult(cacheKey string, now time.Time) (bool, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.cache[cacheKey] + if !ok || !entry.hasResult { + return false, false + } + if now.Before(entry.nextProbeAt) { + return entry.lastResult, true + } + return false, false +} + +func (s *modelProbeCacheState) setCachedResult(cacheKey string, result bool, now time.Time) { + s.mu.Lock() + + entry, ok := s.cache[cacheKey] + if !ok { + entry = &modelProbeCacheEntry{} + s.cache[cacheKey] = entry + } + + entry.lastResult = result + entry.hasResult = true + entry.updatedAt = now + + var delay time.Duration + if result { + entry.successStreak++ + entry.failureStreak = 0 + delay = modelProbeBackoffDelay( + modelProbeSuccessBaseInterval, + modelProbeSuccessMaxInterval, + entry.successStreak, + ) + } else { + entry.failureStreak++ + entry.successStreak = 0 + delay = modelProbeBackoffDelay( + modelProbeFailureBaseInterval, + modelProbeFailureMaxInterval, + entry.failureStreak, + ) + } + + entry.nextProbeAt = now.Add(delay) + + shouldRunTTLGC := modelProbeCacheEntryTTL > 0 && (s.nextTTLGCAt.IsZero() || !now.Before(s.nextTTLGCAt)) + if shouldRunTTLGC { + s.nextTTLGCAt = now.Add(modelProbeTTLGCInterval) + } + shouldRunSizeGC := len(s.cache) > modelProbeCacheMaxEntries + s.mu.Unlock() + + if shouldRunTTLGC || shouldRunSizeGC { + s.gc(now, shouldRunTTLGC) + } +} + +func (s *modelProbeCacheState) gc(now time.Time, runTTL bool) { + type evictionCandidate struct { + key string + updatedAt time.Time + } + + var expireBefore time.Time + if runTTL && modelProbeCacheEntryTTL > 0 { + expireBefore = now.Add(-modelProbeCacheEntryTTL) + } + + s.mu.RLock() + cacheLen := len(s.cache) + if cacheLen == 0 { + s.mu.RUnlock() + return + } + + expiredKeys := make([]string, 0) + if !expireBefore.IsZero() { + expiredKeys = make([]string, 0, min(cacheLen/8+1, 64)) + for key, entry := range s.cache { + if entry.updatedAt.Before(expireBefore) { + expiredKeys = append(expiredKeys, key) + } + } + } + + effectiveLen := cacheLen - len(expiredKeys) + removeCount := max(effectiveLen-modelProbeCacheTrimToEntries, 0) + + candidates := make([]evictionCandidate, 0) + if removeCount > 0 { + candidates = make([]evictionCandidate, 0, effectiveLen) + for key, entry := range s.cache { + if !expireBefore.IsZero() && entry.updatedAt.Before(expireBefore) { + continue + } + candidates = append(candidates, evictionCandidate{key: key, updatedAt: entry.updatedAt}) + } + } + s.mu.RUnlock() + + if len(expiredKeys) == 0 && len(candidates) == 0 { + return + } + + toEvict := map[string]time.Time{} + for i := 0; i < removeCount && len(candidates) > 0; i++ { + oldest := 0 + for j := 1; j < len(candidates); j++ { + if candidates[j].updatedAt.Before(candidates[oldest].updatedAt) { + oldest = j + } + } + victim := candidates[oldest] + toEvict[victim.key] = victim.updatedAt + candidates[oldest] = candidates[len(candidates)-1] + candidates = candidates[:len(candidates)-1] + } + + s.mu.Lock() + defer s.mu.Unlock() + + if !expireBefore.IsZero() { + for _, key := range expiredKeys { + entry, ok := s.cache[key] + if ok && entry.updatedAt.Before(expireBefore) { + delete(s.cache, key) + } + } + } + + for key, victimUpdatedAt := range toEvict { + entry, ok := s.cache[key] + if ok && !entry.updatedAt.After(victimUpdatedAt) { + delete(s.cache, key) + } + } +} + +func modelProbeBackoffDelay(base, maxDelay time.Duration, streak int) time.Duration { + if streak <= 0 { + streak = 1 + } + + shift := min(streak-1, modelProbeBackoffMaxShift) + + delay := base * time.Duration(1< 0 && (delay > maxDelay || delay < 0) { + return maxDelay + } + if delay <= 0 { + return base + } + return delay +} + func modelProbeAPIBase(m *config.ModelConfig) string { if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { return normalizeModelProbeAPIBase(apiBase) @@ -207,7 +474,11 @@ func probeTCPService(raw string) bool { return false } - conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout) + ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout) + defer cancel() + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", hostPort) if err != nil { return false } @@ -262,7 +533,10 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool { } func getJSON(rawURL string, out any, apiKey string) error { - req, err := http.NewRequest(http.MethodGet, rawURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), modelProbeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return err } @@ -270,7 +544,7 @@ func getJSON(rawURL string, out any, apiKey string) error { req.Header.Set("Authorization", "Bearer "+apiKey) } - client := &http.Client{Timeout: modelProbeTimeout} + client := &http.Client{} resp, err := client.Do(req) if err != nil { return err @@ -336,10 +610,29 @@ func ollamaModelMatches(candidate, want string) bool { if candidate == "" || want == "" { return false } - if strings.EqualFold(candidate, want) { - return true + + candidateBase, candidateTag := splitOllamaModel(candidate) + wantBase, wantTag := splitOllamaModel(want) + if candidateBase == "" || wantBase == "" { + return false } - base, _, _ := strings.Cut(candidate, ":") - return strings.EqualFold(base, want) + if candidateTag == "" { + candidateTag = "latest" + } + if wantTag == "" { + wantTag = "latest" + } + + return strings.EqualFold(candidateBase, wantBase) && strings.EqualFold(candidateTag, wantTag) +} + +func splitOllamaModel(raw string) (base, tag string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + + base, tag, _ = strings.Cut(raw, ":") + return strings.TrimSpace(base), strings.TrimSpace(tag) } diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index bfeadf1fe..d5463a856 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -3,7 +3,10 @@ package api import ( "net/http" "net/http/httptest" + "sync" + "sync/atomic" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" ) @@ -85,3 +88,307 @@ func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testin t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio") } } + +func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) { + base := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + AuthMethod: "local", + ConnectMode: "", + } + + m1 := *base + m1.SetAPIKey("key-a") + m2 := *base + m2.SetAPIKey("key-b") + + k1 := modelProbeCacheKey(&m1) + k2 := modelProbeCacheKey(&m2) + if k1 == k2 { + t.Fatal("modelProbeCacheKey() should differ when api key changes") + } +} + +func TestModelProbeCacheKey_NormalizesTrailingSlashInAPIBase(t *testing.T) { + m1 := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + m2 := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1/", + } + + k1 := modelProbeCacheKey(m1) + k2 := modelProbeCacheKey(m2) + if k1 != k2 { + t.Fatalf("modelProbeCacheKey() mismatch for equivalent api_base values: %q vs %q", k1, k2) + } +} + +func TestModelProbeCacheKey_IgnoresDisplayAndConnectionFields(t *testing.T) { + base := &config.ModelConfig{ + ModelName: "vllm-one", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + AuthMethod: "none", + ConnectMode: "http", + } + changed := &config.ModelConfig{ + ModelName: "vllm-two", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + AuthMethod: "token", + ConnectMode: "ws", + } + + k1 := modelProbeCacheKey(base) + k2 := modelProbeCacheKey(changed) + if k1 != k2 { + t.Fatalf("modelProbeCacheKey() should ignore non-probe fields, got %q vs %q", k1, k2) + } +} + +func TestProbeLocalModelAvailability_SuccessBackoff(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000000, 0) + modelProbeNowFunc = func() time.Time { return now } + + calls := 0 + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + calls++ + return true + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + if !probeLocalModelAvailability(model) { + t.Fatal("first probe result = false, want true") + } + if calls != 1 { + t.Fatalf("probe calls after first probe = %d, want 1", calls) + } + + if !probeLocalModelAvailability(model) { + t.Fatal("cached probe result = false, want true") + } + if calls != 1 { + t.Fatalf("probe calls after immediate re-check = %d, want 1", calls) + } + + now = now.Add(modelProbeSuccessBaseInterval) + if !probeLocalModelAvailability(model) { + t.Fatal("second probe result = false, want true") + } + if calls != 2 { + t.Fatalf("probe calls after success backoff window = %d, want 2", calls) + } + + now = now.Add(modelProbeSuccessBaseInterval) + if !probeLocalModelAvailability(model) { + t.Fatal("cached result after doubled backoff = false, want true") + } + if calls != 2 { + t.Fatalf("probe calls before doubled backoff expires = %d, want 2", calls) + } + + now = now.Add(modelProbeSuccessBaseInterval) + if !probeLocalModelAvailability(model) { + t.Fatal("third probe result = false, want true") + } + if calls != 3 { + t.Fatalf("probe calls after doubled backoff expires = %d, want 3", calls) + } +} + +func TestProbeLocalModelAvailability_FailureBackoff(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000100, 0) + modelProbeNowFunc = func() time.Time { return now } + + calls := 0 + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + calls++ + return false + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + if probeLocalModelAvailability(model) { + t.Fatal("first probe result = true, want false") + } + if calls != 1 { + t.Fatalf("probe calls after first failure = %d, want 1", calls) + } + + if probeLocalModelAvailability(model) { + t.Fatal("cached failed probe result = true, want false") + } + if calls != 1 { + t.Fatalf("probe calls after immediate failed re-check = %d, want 1", calls) + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("second failed probe result = true, want false") + } + if calls != 2 { + t.Fatalf("probe calls after failure backoff window = %d, want 2", calls) + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("cached failure after doubled backoff = true, want false") + } + if calls != 2 { + t.Fatalf("probe calls before doubled failure backoff expires = %d, want 2", calls) + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("third failed probe result = true, want false") + } + if calls != 3 { + t.Fatalf("probe calls after doubled failure backoff expires = %d, want 3", calls) + } +} + +func TestProbeLocalModelAvailability_ResultFlipResetsBackoff(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000200, 0) + modelProbeNowFunc = func() time.Time { return now } + + results := []bool{true, false, false} + index := 0 + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + if index >= len(results) { + return false + } + result := results[index] + index++ + return result + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + if !probeLocalModelAvailability(model) { + t.Fatal("first probe result = false, want true") + } + + now = now.Add(modelProbeSuccessBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("second probe result = true, want false") + } + + now = now.Add(modelProbeFailureBaseInterval) + if probeLocalModelAvailability(model) { + t.Fatal("third probe result = true, want false") + } + + if index != 3 { + t.Fatalf("probe invocations = %d, want 3", index) + } +} + +func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) { + resetModelProbeHooks(t) + + now := time.Unix(1700000300, 0) + modelProbeNowFunc = func() time.Time { return now } + + var calls int32 + probeStarted := make(chan struct{}) + releaseProbe := make(chan struct{}) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + if atomic.AddInt32(&calls, 1) == 1 { + close(probeStarted) + } + <-releaseProbe + return true + } + + model := &config.ModelConfig{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + } + + const workers = 8 + var wg sync.WaitGroup + results := make(chan bool, workers) + workerStarted := make(chan struct{}, workers) + + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + workerStarted <- struct{}{} + results <- probeLocalModelAvailability(model) + }() + } + + for range workers { + <-workerStarted + } + + select { + case <-probeStarted: + case <-time.After(200 * time.Millisecond): + t.Fatal("probe did not start in time") + } + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("concurrent probe calls = %d, want 1", got) + } + + close(releaseProbe) + wg.Wait() + close(results) + + for result := range results { + if !result { + t.Fatal("deduplicated probe result = false, want true") + } + } + + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("final probe calls = %d, want 1", got) + } +} + +func TestOllamaModelMatches_WithTagRequiresExactTag(t *testing.T) { + if ollamaModelMatches("llama3:8b", "llama3:7b") { + t.Fatal("ollamaModelMatches() = true, want false for mismatched tags") + } + if !ollamaModelMatches("llama3:7b", "llama3:7b") { + t.Fatal("ollamaModelMatches() = false, want true for exact tagged match") + } + if ollamaModelMatches("llama3:8b", "llama3") { + t.Fatal("ollamaModelMatches() = true, want false when request omits tag (defaults to latest)") + } + if !ollamaModelMatches("llama3:latest", "llama3") { + t.Fatal("ollamaModelMatches() = false, want true when request omits tag and candidate is latest") + } + if !ollamaModelMatches("llama3", "llama3") { + t.Fatal("ollamaModelMatches() = false, want true when both candidate and request omit tag (latest)") + } +} diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index e78de1606..e54d5b77c 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -20,10 +20,14 @@ func resetModelProbeHooks(t *testing.T) { origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc + origNow := modelProbeNowFunc + resetModelProbeCache() t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe + modelProbeNowFunc = origNow + resetModelProbeCache() }) } From a9c76eca2106ead71962faa6dfd0bbfdd21ca1ae Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 14:59:18 +0800 Subject: [PATCH 64/72] bug: fix picoToken is empty when gateway started by launcher (#2241) --- pkg/gateway/gateway.go | 1 + pkg/pid/pidfile.go | 3 +++ web/backend/api/gateway.go | 36 ++++++++------------------------- web/backend/api/gateway_test.go | 10 ++++++++- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index b5e8c1f36..8065a0795 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -149,6 +149,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error // Enforce singleton: write PID file with generated token. pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port) if err != nil { + logger.Warnf("write pid file failed: %v", err) return fmt.Errorf("singleton check failed: %w", err) } defer pid.RemovePidFile(homePath) diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go index 584b9b2b5..69d02bc65 100644 --- a/pkg/pid/pidfile.go +++ b/pkg/pid/pidfile.go @@ -94,6 +94,7 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) { os.Remove(tmp) return nil, fmt.Errorf("failed to rename pid file: %w", err) } + logger.Debugf("wrote pid file: %s success", pidPath) return data, nil } @@ -108,10 +109,12 @@ func ReadPidFileWithCheck(homePath string) *PidFileData { pidPath := pidFilePath(homePath) data, err := readPidFileUnlocked(pidPath) if err != nil { + logger.Debugf("failed to read pid file: %s", err) return nil } if !isProcessRunning(data.PID) { + logger.Debugf("process not running, remove pid file: %s", pidPath) os.Remove(pidPath) return nil } diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 6f5f5dd5d..b54e55bac 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -628,6 +628,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.mu.Lock() if gateway.cmd == cmd { gateway.pidData = pd + gateway.picoToken = cfg.Channels.Pico.Token.String() setGatewayRuntimeStatusLocked("running") } gateway.mu.Unlock() @@ -922,34 +923,13 @@ func (h *Handler) gatewayStatusData() map[string]any { data["pid"] = pidData.PID gateway.mu.Unlock() } else { - // Fallback: probe health endpoint to get pid and status - _, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second) - if err != nil { - gateway.mu.Lock() - data["gateway_status"] = gatewayStatusWithoutHealthLocked() - gateway.pidData = nil - gateway.mu.Unlock() - logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err)) - } else { - logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode)) - if statusCode != http.StatusOK { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("error") - gateway.pidData = nil - gateway.mu.Unlock() - data["gateway_status"] = "error" - data["status_code"] = statusCode - } else { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("running") - bootDefaultModel := gateway.bootDefaultModel - if bootDefaultModel != "" { - data["boot_default_model"] = bootDefaultModel - } - data["gateway_status"] = "running" - gateway.mu.Unlock() - } - } + // Intentionally skip health probe here; the startup goroutine + // (startGatewayLocked) already handles liveness detection via + // pidFile polling and health fallback. + gateway.mu.Lock() + data["gateway_status"] = gatewayStatusWithoutHealthLocked() + gateway.pidData = nil + gateway.mu.Unlock() } gatewayStatus, _ := data["gateway_status"].(string) diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index fc8ee13f3..2ddb1fd8d 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -15,8 +15,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + ppid "github.com/sipeed/picoclaw/pkg/pid" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -444,7 +447,7 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) } } -func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { +func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) { resetGatewayTestState(t) configPath := filepath.Join(t.TempDir(), "config.json") @@ -468,6 +471,9 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) { return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil } + _, err := ppid.WritePidFile(globalConfigDir(), "localhost", 0) + require.NoError(t, err) + rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) mux.ServeHTTP(rec, req) @@ -513,6 +519,8 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) { if err != nil { t.Fatalf("FindProcess() error = %v", err) } + _, err = ppid.WritePidFile(globalConfigDir(), "localhost", 0) + require.NoError(t, err) bootSignature := computeConfigSignature(cfg) gateway.mu.Lock() From c0464bdd5d531cadd055b7551617f0340ab9b649 Mon Sep 17 00:00:00 2001 From: wenjie Date: Wed, 1 Apr 2026 19:25:31 +0800 Subject: [PATCH 65/72] feat(web): add skill marketplace hub and registry install flow (#2246) - add backend APIs for searching and installing registry skills, including origin metadata and concurrency-safe workspace writes - introduce /agent/hub as the default agent entry with marketplace search and install UI - refactor the skills and tools pages with filtering, dialogs, detail views, import validation, and updated i18n - expand backend tests for search, install, import, rollback, and concurrent requests --- web/backend/api/skills.go | 887 +++++++++++++- web/backend/api/skills_test.go | 1082 +++++++++++++++++ web/frontend/src/api/skills.ts | 95 +- .../src/components/agent/hub/hub-page.tsx | 51 + .../agent/hub/market-skill-card.tsx | 132 ++ .../components/agent/hub/results-panel.tsx | 135 ++ .../src/components/agent/hub/search-panel.tsx | 91 ++ .../src/components/agent/hub/tool-support.ts | 54 + .../agent/hub/use-hub-marketplace.ts | 211 ++++ .../components/agent/skills/delete-dialog.tsx | 65 + .../components/agent/skills/detail-sheet.tsx | 249 ++++ .../components/agent/skills/filter-bar.tsx | 136 +++ .../components/agent/skills/import-dialog.tsx | 160 +++ .../components/agent/skills/origin-badge.tsx | 46 + .../components/agent/skills/origin-utils.ts | 86 ++ .../components/agent/skills/page-skeleton.tsx | 27 + .../components/agent/skills/skill-card.tsx | 84 ++ .../components/agent/skills/skills-list.tsx | 86 ++ .../components/agent/skills/skills-page.tsx | 160 +++ .../src/components/agent/skills/stats.tsx | 39 + .../src/components/agent/skills/types.ts | 17 + .../agent/skills/use-skills-page.ts | 336 +++++ .../src/components/agent/tools/tools-page.tsx | 288 +++++ web/frontend/src/components/app-sidebar.tsx | 23 +- .../src/components/skills/skills-page.tsx | 319 ----- .../src/components/tools/tools-page.tsx | 192 --- web/frontend/src/components/ui/dialog.tsx | 163 +++ web/frontend/src/i18n/locales/en.json | 82 +- web/frontend/src/i18n/locales/zh.json | 82 +- web/frontend/src/routeTree.gen.ts | 21 + web/frontend/src/routes/agent.tsx | 2 +- web/frontend/src/routes/agent/hub.tsx | 11 + web/frontend/src/routes/agent/skills.tsx | 2 +- web/frontend/src/routes/agent/tools.tsx | 2 +- 34 files changed, 4816 insertions(+), 600 deletions(-) create mode 100644 web/frontend/src/components/agent/hub/hub-page.tsx create mode 100644 web/frontend/src/components/agent/hub/market-skill-card.tsx create mode 100644 web/frontend/src/components/agent/hub/results-panel.tsx create mode 100644 web/frontend/src/components/agent/hub/search-panel.tsx create mode 100644 web/frontend/src/components/agent/hub/tool-support.ts create mode 100644 web/frontend/src/components/agent/hub/use-hub-marketplace.ts create mode 100644 web/frontend/src/components/agent/skills/delete-dialog.tsx create mode 100644 web/frontend/src/components/agent/skills/detail-sheet.tsx create mode 100644 web/frontend/src/components/agent/skills/filter-bar.tsx create mode 100644 web/frontend/src/components/agent/skills/import-dialog.tsx create mode 100644 web/frontend/src/components/agent/skills/origin-badge.tsx create mode 100644 web/frontend/src/components/agent/skills/origin-utils.ts create mode 100644 web/frontend/src/components/agent/skills/page-skeleton.tsx create mode 100644 web/frontend/src/components/agent/skills/skill-card.tsx create mode 100644 web/frontend/src/components/agent/skills/skills-list.tsx create mode 100644 web/frontend/src/components/agent/skills/skills-page.tsx create mode 100644 web/frontend/src/components/agent/skills/stats.tsx create mode 100644 web/frontend/src/components/agent/skills/types.ts create mode 100644 web/frontend/src/components/agent/skills/use-skills-page.ts create mode 100644 web/frontend/src/components/agent/tools/tools-page.tsx delete mode 100644 web/frontend/src/components/skills/skills-page.tsx delete mode 100644 web/frontend/src/components/tools/tools-page.tsx create mode 100644 web/frontend/src/components/ui/dialog.tsx create mode 100644 web/frontend/src/routes/agent/hub.tsx diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go index b2036f66c..2c054c41b 100644 --- a/web/backend/api/skills.go +++ b/web/backend/api/skills.go @@ -1,40 +1,115 @@ package api import ( + "bytes" "encoding/json" + "errors" "fmt" "io" + "io/fs" "net/http" + "net/url" "os" "path/filepath" "regexp" + "strconv" "strings" + "sync" + "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/utils" ) type skillSupportResponse struct { - Skills []skills.SkillInfo `json:"skills"` + Skills []skillSupportItem `json:"skills"` +} + +type skillSupportItem struct { + Name string `json:"name"` + Path string `json:"path"` + Source string `json:"source"` + Description string `json:"description"` + OriginKind string `json:"origin_kind"` + RegistryName string `json:"registry_name,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + InstalledVersion string `json:"installed_version,omitempty"` + InstalledAt int64 `json:"installed_at,omitempty"` } type skillDetailResponse struct { - Name string `json:"name"` - Path string `json:"path"` - Source string `json:"source"` - Description string `json:"description"` - Content string `json:"content"` + skillSupportItem + Content string `json:"content"` +} + +type skillSearchResultItem struct { + Score float64 `json:"score"` + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Summary string `json:"summary"` + Version string `json:"version"` + RegistryName string `json:"registry_name"` + URL string `json:"url,omitempty"` + Installed bool `json:"installed"` + InstalledName string `json:"installed_name,omitempty"` +} + +type skillSearchResponse struct { + Results []skillSearchResultItem `json:"results"` + Limit int `json:"limit"` + Offset int `json:"offset"` + NextOffset int `json:"next_offset,omitempty"` + HasMore bool `json:"has_more"` +} + +type installSkillRequest struct { + Slug string `json:"slug"` + Registry string `json:"registry"` + Version string `json:"version,omitempty"` + Force bool `json:"force,omitempty"` +} + +type installSkillResponse struct { + Status string `json:"status"` + Slug string `json:"slug"` + Registry string `json:"registry"` + Version string `json:"version"` + Summary string `json:"summary,omitempty"` + IsSuspicious bool `json:"is_suspicious,omitempty"` + InstalledSkill *skillSupportItem `json:"skill,omitempty"` +} + +type installedSkillOriginMeta struct { + Version int `json:"version"` + OriginKind string `json:"origin_kind,omitempty"` + Registry string `json:"registry,omitempty"` + Slug string `json:"slug,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + InstalledVersion string `json:"installed_version,omitempty"` + InstalledAt int64 `json:"installed_at"` } var ( skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) + persistSkillOriginMeta = writeSkillOriginMeta + workspaceSkillWriteMu sync.Mutex + errImportedSkillExists = errors.New("skill already exists") +) + +const ( + maxImportedSkillSize = 1 << 20 + maxRegistrySearchFanout = 1000 ) func (h *Handler) registerSkillRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/skills", h.handleListSkills) mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill) + mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills) + mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill) mux.HandleFunc("POST /api/skills/import", h.handleImportSkill) mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill) } @@ -46,11 +121,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) { return } - loader := newSkillsLoader(cfg.WorkspacePath()) + items, err := buildSkillSupportItems(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skillSupportResponse{ - Skills: loader.ListSkills(), + Skills: items, }) } @@ -61,16 +140,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { return } - loader := newSkillsLoader(cfg.WorkspacePath()) + skillItems, err := buildSkillSupportItems(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError) + return + } name := r.PathValue("name") - allSkills := loader.ListSkills() - - for _, skill := range allSkills { - if skill.Name != name { + for _, skillItem := range skillItems { + if skillItem.Name != name { continue } - content, err := loadSkillContent(skill.Path) + content, err := loadSkillContent(skillItem.Path) if err != nil { http.Error(w, "Skill content not found", http.StatusNotFound) return @@ -78,11 +159,8 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skillDetailResponse{ - Name: skill.Name, - Path: skill.Path, - Source: skill.Source, - Description: skill.Description, - Content: content, + skillSupportItem: skillItem, + Content: content, }) return } @@ -90,6 +168,266 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { http.Error(w, "Skill not found", http.StatusNotFound) } +func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) { + cfg, loadErr := config.LoadConfig(h.configPath) + if loadErr != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError) + return + } + if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + + limit := 20 + if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { + parsedLimit, parseErr := strconv.Atoi(rawLimit) + if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 { + http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest) + return + } + limit = parsedLimit + } + offset := 0 + if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" { + parsedOffset, parseErr := strconv.Atoi(rawOffset) + if parseErr != nil || parsedOffset < 0 { + http.Error(w, "offset must be 0 or greater", http.StatusBadRequest) + return + } + offset = parsedOffset + } + + installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError) + return + } + + if query == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillSearchResponse{ + Results: []skillSearchResultItem{}, + Limit: limit, + Offset: offset, + HasMore: false, + }) + return + } + + registryMgr := newSkillsRegistryManager(cfg) + searchLimit := offset + limit + 1 + if searchLimit > maxRegistrySearchFanout { + searchLimit = maxRegistrySearchFanout + } + results, err := registryMgr.SearchAll(r.Context(), query, searchLimit) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway) + return + } + + if offset > len(results) { + offset = len(results) + } + + end := offset + limit + if end > len(results) { + end = len(results) + } + + pageResults := results[offset:end] + response := make([]skillSearchResultItem, 0, len(pageResults)) + for _, result := range pageResults { + installedSkill, installed := installedSkills[result.Slug] + item := skillSearchResultItem{ + Score: result.Score, + Slug: result.Slug, + DisplayName: result.DisplayName, + Summary: result.Summary, + Version: result.Version, + RegistryName: result.RegistryName, + URL: registrySkillURL(cfg, result.RegistryName, result.Slug), + Installed: installed, + } + if installed { + item.InstalledName = installedSkill.Name + } + response = append(response, item) + } + + w.Header().Set("Content-Type", "application/json") + nextOffset := 0 + hasMore := len(results) > end + if hasMore { + nextOffset = end + } + json.NewEncoder(w).Encode(skillSearchResponse{ + Results: response, + Limit: limit, + Offset: offset, + NextOffset: nextOffset, + HasMore: hasMore, + }) +} + +func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) { + cfg, loadErr := config.LoadConfig(h.configPath) + if loadErr != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError) + return + } + if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + + var req installSkillRequest + if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest) + return + } + + req.Slug = strings.TrimSpace(req.Slug) + req.Registry = strings.TrimSpace(req.Registry) + req.Version = strings.TrimSpace(req.Version) + + if validateErr := utils.ValidateSkillIdentifier(req.Slug); validateErr != nil { + http.Error( + w, + fmt.Sprintf("invalid slug %q: error: %s", req.Slug, validateErr.Error()), + http.StatusBadRequest, + ) + return + } + if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil { + http.Error( + w, + fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()), + http.StatusBadRequest, + ) + return + } + + registryMgr := newSkillsRegistryManager(cfg) + registry := registryMgr.GetRegistry(req.Registry) + if registry == nil { + http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest) + return + } + + workspace := cfg.WorkspacePath() + skillsRoot := filepath.Join(workspace, "skills") + targetDir := filepath.Join(workspace, "skills", req.Slug) + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() + + targetExists := false + if _, statErr := os.Stat(targetDir); statErr == nil { + targetExists = true + } else if !os.IsNotExist(statErr) { + http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError) + return + } + + if !req.Force && targetExists { + http.Error(w, fmt.Sprintf("skill %q already installed at %s", req.Slug, targetDir), http.StatusConflict) + return + } + if err := os.MkdirAll(skillsRoot, 0o755); err != nil { + http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", err), http.StatusInternalServerError) + return + } + + stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, req.Slug) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError) + return + } + defer os.RemoveAll(stagedWorkspaceRoot) + + result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway) + return + } + if result.IsMalwareBlocked { + http.Error( + w, + fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug), + http.StatusForbidden, + ) + return + } + + if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, req.Slug) == nil { + http.Error( + w, + fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug), + http.StatusBadGateway, + ) + return + } + + installedAt := time.Now().UnixMilli() + if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{ + Version: 1, + OriginKind: "third_party", + Registry: registry.Name(), + Slug: req.Slug, + RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug), + InstalledVersion: result.Version, + InstalledAt: installedAt, + }); err != nil { + http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError) + return + } + + if err := commitStagedSkillInstall( + stagedWorkspaceRoot, + stagedTargetDir, + targetDir, + req.Force && targetExists, + ); err != nil { + http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError) + return + } + + validatedSkill := findWorkspaceSkillByDirectory(cfg, req.Slug) + if validatedSkill == nil { + http.Error( + w, + fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug), + http.StatusBadGateway, + ) + return + } + + installedSkill := &skillSupportItem{ + Name: validatedSkill.Name, + Path: validatedSkill.Path, + Source: validatedSkill.Source, + Description: validatedSkill.Description, + OriginKind: "third_party", + RegistryName: registry.Name(), + RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug), + InstalledVersion: result.Version, + InstalledAt: installedAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(installSkillResponse{ + Status: "ok", + Slug: req.Slug, + Registry: registry.Name(), + Version: result.Version, + Summary: result.Summary, + IsSuspicious: result.IsSuspicious, + InstalledSkill: installedSkill, + }) +} + func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { @@ -110,54 +448,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { } defer uploadedFile.Close() - content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1)) + content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1)) if err != nil { http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) return } - if len(content) > 1<<20 { + if len(content) > maxImportedSkillSize { http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) return } + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() - skillName, err := normalizeImportedSkillName(fileHeader.Filename, content) + importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), statusCode) return } - content = normalizeImportedSkillContent(content, skillName) - - workspace := cfg.WorkspacePath() - skillDir := filepath.Join(workspace, "skills", skillName) - skillFile := filepath.Join(skillDir, "SKILL.md") - if _, err := os.Stat(skillDir); err == nil { - http.Error(w, "skill already exists", http.StatusConflict) - return - } - - if err := os.MkdirAll(skillDir, 0o755); err != nil { - http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError) - return - } - if err := os.WriteFile(skillFile, content, 0o644); err != nil { - http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError) - return - } - - loader := newSkillsLoader(workspace) - for _, skill := range loader.ListSkills() { - if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(skill) - return - } - } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "name": skillName, - "path": skillFile, - }) + json.NewEncoder(w).Encode(importedSkill) } func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { @@ -169,6 +479,9 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { loader := newSkillsLoader(cfg.WorkspacePath()) name := r.PathValue("name") + workspaceSkillWriteMu.Lock() + defer workspaceSkillWriteMu.Unlock() + for _, skill := range loader.ListSkills() { if skill.Name != name { continue @@ -197,12 +510,274 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader { ) } +func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager { + clawHubConfig := cfg.Tools.Skills.Registries.ClawHub + return skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ + MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + ClawHub: skills.ClawHubConfig{ + Enabled: clawHubConfig.Enabled, + BaseURL: clawHubConfig.BaseURL, + AuthToken: clawHubConfig.AuthToken.String(), + SearchPath: clawHubConfig.SearchPath, + SkillsPath: clawHubConfig.SkillsPath, + DownloadPath: clawHubConfig.DownloadPath, + Timeout: clawHubConfig.Timeout, + MaxZipSize: clawHubConfig.MaxZipSize, + MaxResponseSize: clawHubConfig.MaxResponseSize, + }, + }) +} + +func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error { + if !cfg.Tools.IsToolEnabled("skills") { + return fmt.Errorf("tools.skills is disabled") + } + if !cfg.Tools.IsToolEnabled(toolName) { + return fmt.Errorf("%s is disabled", toolName) + } + return nil +} + +func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) { + rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills() + items := make([]skillSupportItem, 0, len(rawSkills)) + for _, skill := range rawSkills { + item, err := enrichSkillInfo(cfg, skill) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) { + result := make(map[string]skillSupportItem) + items, err := buildSkillSupportItems(cfg) + if err != nil { + return nil, err + } + for _, skill := range items { + if skill.Source != "workspace" { + continue + } + dir := filepath.Base(filepath.Dir(skill.Path)) + if dir == "" { + continue + } + result[dir] = skill + } + return result, nil +} + +func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) { + result := make(map[string]skillSupportItem) + items, err := buildSkillSupportItems(cfg) + if err != nil { + return nil, err + } + for _, skill := range items { + if skill.Source != "workspace" { + continue + } + + key := filepath.Base(filepath.Dir(skill.Path)) + if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" { + key = meta.Slug + } + if key == "" { + continue + } + result[key] = skill + } + return result, nil +} + +func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem { + items, err := buildWorkspaceSkillItemsByDirectory(cfg) + if err != nil { + return nil + } + skill, ok := items[directory] + if !ok { + return nil + } + return &skill +} + +func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo { + loader := skills.NewSkillsLoader(workspace, "", "") + for _, skill := range loader.ListSkills() { + if skill.Source != "workspace" { + continue + } + if filepath.Base(filepath.Dir(skill.Path)) != directory { + continue + } + skillCopy := skill + return &skillCopy + } + return nil +} + +func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) { + stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*") + if err != nil { + return "", "", err + } + stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug) + return stagedWorkspaceRoot, stagedTargetDir, nil +} + +func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error { + if !replaceExisting { + return os.Rename(stagedTargetDir, targetDir) + } + + backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*") + if err != nil { + return err + } + + if err := os.Rename(targetDir, backupDir); err != nil { + return fmt.Errorf("failed to move existing skill aside: %w", err) + } + + if err := os.Rename(stagedTargetDir, targetDir); err != nil { + if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil { + return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr) + } + return fmt.Errorf("failed to activate replacement: %w", err) + } + + _ = os.RemoveAll(backupDir) + _ = os.RemoveAll(stagedWorkspaceRoot) + return nil +} + +func reserveTempDirPath(parent, pattern string) (string, error) { + tempDir, err := os.MkdirTemp(parent, pattern) + if err != nil { + return "", err + } + if err := os.Remove(tempDir); err != nil { + return "", err + } + return tempDir, nil +} + +func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) { + item := skillSupportItem{ + Name: skill.Name, + Path: skill.Path, + Source: skill.Source, + Description: skill.Description, + OriginKind: "builtin", + } + + switch skill.Source { + case "builtin": + item.OriginKind = "builtin" + case "global": + item.OriginKind = "builtin" + case "workspace": + meta, err := readInstalledSkillOriginMeta(skill.Path) + if err == nil && meta != nil { + switch meta.OriginKind { + case "manual": + item.OriginKind = "manual" + item.InstalledAt = meta.InstalledAt + case "third_party": + item.OriginKind = "third_party" + item.RegistryName = meta.Registry + item.RegistryURL = registrySkillURLFromMeta(cfg, meta) + item.InstalledVersion = meta.InstalledVersion + item.InstalledAt = meta.InstalledAt + default: + if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" { + item.OriginKind = "third_party" + item.RegistryName = meta.Registry + item.RegistryURL = registrySkillURLFromMeta(cfg, meta) + item.InstalledVersion = meta.InstalledVersion + item.InstalledAt = meta.InstalledAt + } else { + item.OriginKind = "builtin" + item.InstalledAt = meta.InstalledAt + } + } + } else { + item.OriginKind = "builtin" + } + default: + item.OriginKind = "builtin" + } + + return item, nil +} + +func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) { + metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json") + data, err := os.ReadFile(metaPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var meta installedSkillOriginMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) +} + +func registrySkillURL(cfg *config.Config, registryName, slug string) string { + switch registryName { + case "clawhub": + baseURL := strings.TrimRight(cfg.Tools.Skills.Registries.ClawHub.BaseURL, "/") + if baseURL == "" { + baseURL = "https://clawhub.ai" + } + return baseURL + "/skills/" + url.PathEscape(slug) + default: + return "" + } +} + +func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string { + if meta == nil || meta.Slug == "" { + return "" + } + if meta.RegistryURL != "" { + return meta.RegistryURL + } + if cfg == nil || meta.Registry == "" { + return "" + } + return registrySkillURL(cfg, meta.Registry, meta.Slug) +} + func normalizeImportedSkillName(filename string, content []byte) (string, error) { + return normalizeImportedSkillNameWithHint(filename, "", content) +} + +func normalizeImportedSkillNameWithHint(filename, directoryHint string, content []byte) (string, error) { rawContent := strings.ReplaceAll(string(content), "\r\n", "\n") rawContent = strings.ReplaceAll(rawContent, "\r", "\n") metadata, _ := extractImportedSkillMetadata(rawContent) raw := strings.TrimSpace(metadata["name"]) + if raw == "" { + raw = strings.TrimSpace(directoryHint) + } if raw == "" { raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) } @@ -259,6 +834,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte { return []byte(builder.String()) } +func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) { + if isImportedSkillArchive(filename, content) { + return importUploadedSkillArchive(cfg, filename, content) + } + return importUploadedMarkdownSkill(cfg, filename, content) +} + +func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) { + skillName, err := normalizeImportedSkillName(filename, content) + if err != nil { + return nil, http.StatusBadRequest, err + } + + normalizedContent := normalizeImportedSkillContent(content, skillName) + workspace := cfg.WorkspacePath() + skillDir := filepath.Join(workspace, "skills", skillName) + skillFile := filepath.Join(skillDir, "SKILL.md") + + if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil { + return nil, statusCodeForImportedSkillWriteError(err), err + } + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err) + } + if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err) + } + + return finalizeImportedSkill(cfg, skillDir, skillName, false) +} + +func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) { + tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*") + if tempDirErr != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr) + } + defer os.RemoveAll(tmpDir) + + archivePath := filepath.Join(tmpDir, "import.zip") + if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr) + } + + extractDir := filepath.Join(tmpDir, "extract") + if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil { + return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr) + } + + skillRoot, err := findImportedSkillRoot(extractDir) + if err != nil { + return nil, http.StatusBadRequest, err + } + + skillFile := filepath.Join(skillRoot, "SKILL.md") + skillContent, err := os.ReadFile(skillFile) + if err != nil { + return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err) + } + + directoryHint := "" + if filepath.Clean(skillRoot) != filepath.Clean(extractDir) { + directoryHint = filepath.Base(skillRoot) + } + skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent) + if err != nil { + return nil, http.StatusBadRequest, err + } + + workspace := cfg.WorkspacePath() + skillDir := filepath.Join(workspace, "skills", skillName) + if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil { + return nil, statusCodeForImportedSkillWriteError(err), err + } + if err := copyImportedSkillTree(skillRoot, skillDir); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err) + } + + normalizedContent := normalizeImportedSkillContent(skillContent, skillName) + if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err) + } + + return finalizeImportedSkill(cfg, skillDir, skillName, true) +} + +func isImportedSkillArchive(filename string, content []byte) bool { + if strings.EqualFold(filepath.Ext(filename), ".zip") { + return true + } + return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04")) +} + +func ensureWorkspaceSkillDoesNotExist(skillDir string) error { + if _, err := os.Stat(skillDir); err == nil { + return errImportedSkillExists + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to inspect skill directory: %w", err) + } + return nil +} + +func statusCodeForImportedSkillWriteError(err error) int { + if err == nil { + return http.StatusOK + } + if errors.Is(err, errImportedSkillExists) { + return http.StatusConflict + } + return http.StatusInternalServerError +} + +func finalizeImportedSkill( + cfg *config.Config, + skillDir string, + skillName string, + requireValidatedSkill bool, +) (*skillSupportItem, int, error) { + if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{ + Version: 1, + OriginKind: "manual", + InstalledAt: time.Now().UnixMilli(), + }); err != nil { + _ = os.RemoveAll(skillDir) + return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err) + } + + if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil { + return importedSkill, http.StatusOK, nil + } + + if requireValidatedSkill { + _ = os.RemoveAll(skillDir) + return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill") + } + + return &skillSupportItem{ + Name: skillName, + Path: filepath.Join(skillDir, "SKILL.md"), + Source: "workspace", + Description: "Imported skill", + OriginKind: "manual", + }, http.StatusOK, nil +} + +func findImportedSkillRoot(extractDir string) (string, error) { + skillFiles := make([]string, 0, 1) + err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if d.Name() == "SKILL.md" { + skillFiles = append(skillFiles, path) + } + return nil + }) + if err != nil { + return "", fmt.Errorf("failed to inspect ZIP archive: %w", err) + } + + switch len(skillFiles) { + case 0: + return "", fmt.Errorf("ZIP archive must contain a SKILL.md file") + case 1: + return filepath.Dir(skillFiles[0]), nil + default: + return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file") + } +} + +func copyImportedSkillTree(srcDir, destDir string) error { + return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if relPath == "." { + return os.MkdirAll(destDir, 0o755) + } + + destPath := filepath.Join(destDir, relPath) + info, err := d.Info() + if err != nil { + return err + } + if d.IsDir() { + return os.MkdirAll(destPath, 0o755) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("archive contains unsupported file %q", relPath) + } + return fileutil.CopyFile(path, destPath, info.Mode().Perm()) + }) +} + func extractImportedSkillMetadata(raw string) (map[string]string, string) { matches := importedSkillFrontmatter.FindStringSubmatch(raw) if len(matches) != 2 { diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go index 3289d5b33..17aef485e 100644 --- a/web/backend/api/skills_test.go +++ b/web/backend/api/skills_test.go @@ -1,15 +1,19 @@ package api import ( + "archive/zip" "bytes" "encoding/json" + "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "strconv" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" ) @@ -99,8 +103,10 @@ func TestHandleListSkills(t *testing.T) { } gotSkills := make(map[string]string, len(resp.Skills)) + gotOriginKinds := make(map[string]string, len(resp.Skills)) for _, skill := range resp.Skills { gotSkills[skill.Name] = skill.Source + gotOriginKinds[skill.Name] = skill.OriginKind } if gotSkills["workspace-skill"] != "workspace" { t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"]) @@ -111,6 +117,15 @@ func TestHandleListSkills(t *testing.T) { if gotSkills["builtin-skill"] != "builtin" { t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"]) } + if gotOriginKinds["workspace-skill"] != "builtin" { + t.Fatalf("workspace-skill origin_kind = %q, want builtin", gotOriginKinds["workspace-skill"]) + } + if gotOriginKinds["global-skill"] != "builtin" { + t.Fatalf("global-skill origin_kind = %q, want builtin", gotOriginKinds["global-skill"]) + } + if gotOriginKinds["builtin-skill"] != "builtin" { + t.Fatalf("builtin-skill origin_kind = %q, want builtin", gotOriginKinds["builtin-skill"]) + } } func TestHandleGetSkill(t *testing.T) { @@ -162,6 +177,9 @@ func TestHandleGetSkill(t *testing.T) { if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" { t.Fatalf("unexpected response: %#v", resp) } + if resp.OriginKind != "builtin" { + t.Fatalf("resp.OriginKind = %q, want builtin", resp.OriginKind) + } if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" { t.Fatalf("content = %q", resp.Content) } @@ -271,6 +289,17 @@ func TestHandleImportSkill(t *testing.T) { if string(content) != expected { t.Fatalf("saved skill content mismatch:\n%s", string(content)) } + metaContent, err := os.ReadFile(filepath.Join(workspace, "skills", "plain-skill", ".skill-origin.json")) + if err != nil { + t.Fatalf("ReadFile(origin metadata) error = %v", err) + } + var originMeta installedSkillOriginMeta + if err := json.Unmarshal(metaContent, &originMeta); err != nil { + t.Fatalf("Unmarshal(origin metadata) error = %v", err) + } + if originMeta.OriginKind != "manual" { + t.Fatalf("originMeta.OriginKind = %q, want manual", originMeta.OriginKind) + } rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil) @@ -293,6 +322,174 @@ func TestHandleImportSkill(t *testing.T) { } } +func TestHandleImportSkillZip(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + zipContent := buildSkillZip(t, map[string]string{ + "Wrapped Skill/SKILL.md": "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n# Wrapped Skill\n\nUse this skill from zip.\n", + "Wrapped Skill/docs/README.md": "# Extra file\n", + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, createErr := writer.CreateFormFile("file", "Wrapped Skill.zip") + if createErr != nil { + t.Fatalf("CreateFormFile() error = %v", createErr) + } + if _, writeErr := part.Write(zipContent); writeErr != nil { + t.Fatalf("Write(zipContent) error = %v", writeErr) + } + if closeErr := writer.Close(); closeErr != nil { + t.Fatalf("Close() error = %v", closeErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "wrapped-skill") + skillFile := filepath.Join(skillDir, "SKILL.md") + content, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + expected := "---\nname: wrapped-skill\ndescription: Wrapped skill\n---\n\n# Wrapped Skill\n\nUse this skill from zip.\n" + if string(content) != expected { + t.Fatalf("saved skill content mismatch:\n%s", string(content)) + } + + extraFile := filepath.Join(skillDir, "docs", "README.md") + extraContent, err := os.ReadFile(extraFile) + if err != nil { + t.Fatalf("ReadFile(extra file) error = %v", err) + } + if string(extraContent) != "# Extra file\n" { + t.Fatalf("extra file content = %q", string(extraContent)) + } +} + +func TestHandleImportSkillZipRejectsArchiveWithoutSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + zipContent := buildSkillZip(t, map[string]string{ + "README.md": "# Not a skill\n", + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "invalid.zip") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := part.Write(zipContent); err != nil { + t.Fatalf("Write(zipContent) error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(workspace, "skills", "invalid")); !os.IsNotExist(err) { + t.Fatalf("invalid archive should not leave behind a skill dir, stat err=%v", err) + } +} + +func TestHandleImportSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + previousPersist := persistSkillOriginMeta + persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error { + return errors.New("forced metadata failure") + } + defer func() { + persistSkillOriginMeta = previousPersist + }() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "Rollback Skill.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := io.WriteString(part, "# Rollback Skill\n"); err != nil { + t.Fatalf("WriteString() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "rollback-skill") + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err) + } +} + func TestHandleDeleteSkill(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -334,3 +531,888 @@ func TestHandleDeleteSkill(t *testing.T) { t.Fatalf("skill directory should be removed, stat err=%v", err) } } + +func TestHandleSearchSkills(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + if err := os.MkdirAll(filepath.Join(workspace, "skills", "github"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(workspace, "skills", "github", "SKILL.md"), + []byte("---\nname: github\ndescription: Installed GitHub skill\n---\n# GitHub\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "github" { + t.Fatalf("query = %q, want github", got) + } + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.95, + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub integration skill", + "version": "1.2.3", + }, + { + "score": 0.87, + "slug": "jira", + "displayName": "Jira", + "summary": "Issue tracker skill", + "version": "0.9.0", + }, + }, + }) + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Limit != 5 { + t.Fatalf("limit = %d, want 5", resp.Limit) + } + if resp.Offset != 0 { + t.Fatalf("offset = %d, want 0", resp.Offset) + } + if resp.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(resp.Results) != 2 { + t.Fatalf("results count = %d, want 2", len(resp.Results)) + } + if resp.Results[0].URL != server.URL+"/skills/github" { + t.Fatalf("first result URL = %q, want %q", resp.Results[0].URL, server.URL+"/skills/github") + } + if !resp.Results[0].Installed || resp.Results[0].InstalledName != "github" { + t.Fatalf("first result should be treated as occupying the workspace slug, got %#v", resp.Results[0]) + } + if resp.Results[1].Installed { + t.Fatalf("second result should not be installed, got %#v", resp.Results[1]) + } +} + +func TestHandleSearchSkillsPagination(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("limit = %q, want 5", got) + } + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.99, + "slug": "skill-1", + "displayName": "Skill 1", + "summary": "Summary 1", + "version": "1.0.0", + }, + { + "score": 0.98, + "slug": "skill-2", + "displayName": "Skill 2", + "summary": "Summary 2", + "version": "1.0.0", + }, + { + "score": 0.97, + "slug": "skill-3", + "displayName": "Skill 3", + "summary": "Summary 3", + "version": "1.0.0", + }, + { + "score": 0.96, + "slug": "skill-4", + "displayName": "Skill 4", + "summary": "Summary 4", + "version": "1.0.0", + }, + }, + }) + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=2&offset=2", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Limit != 2 { + t.Fatalf("limit = %d, want 2", resp.Limit) + } + if resp.Offset != 2 { + t.Fatalf("offset = %d, want 2", resp.Offset) + } + if resp.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(resp.Results) != 2 { + t.Fatalf("results count = %d, want 2", len(resp.Results)) + } + if resp.Results[0].Slug != "skill-3" || resp.Results[1].Slug != "skill-4" { + t.Fatalf("unexpected paged results: %#v", resp.Results) + } + if resp.NextOffset != 0 { + t.Fatalf("next_offset = %d, want 0", resp.NextOffset) + } +} + +func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("limit"); got != strconv.Itoa(maxRegistrySearchFanout) { + t.Fatalf("limit = %q, want %d", got, maxRegistrySearchFanout) + } + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.99, + "slug": "skill-1", + "displayName": "Skill 1", + "summary": "Summary 1", + "version": "1.0.0", + }, + }, + }) + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=20&offset=100000", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSearchResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Results) != 0 { + t.Fatalf("results count = %d, want 0", len(resp.Results)) + } +} + +func TestHandleInstallSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n\nUse this skill.\n", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/search": + json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "score": 0.95, + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "version": "1.2.3", + }, + }, + }) + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + if got := r.URL.Query().Get("slug"); got != "github" { + t.Fatalf("slug = %q, want github", got) + } + if got := r.URL.Query().Get("version"); got != "1.2.3" { + t.Fatalf("version = %q, want 1.2.3", got) + } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp installSkillResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Status != "ok" || resp.Version != "1.2.3" || resp.InstalledSkill == nil { + t.Fatalf("unexpected response: %#v", resp) + } + if resp.InstalledSkill.OriginKind != "third_party" { + t.Fatalf("resp.InstalledSkill.OriginKind = %q, want third_party", resp.InstalledSkill.OriginKind) + } + if resp.InstalledSkill.RegistryURL != server.URL+"/skills/github" { + t.Fatalf( + "resp.InstalledSkill.RegistryURL = %q, want %q", + resp.InstalledSkill.RegistryURL, + server.URL+"/skills/github", + ) + } + + skillFile := filepath.Join(workspace, "skills", "github", "SKILL.md") + if _, err := os.Stat(skillFile); err != nil { + t.Fatalf("installed skill file missing: %v", err) + } + if _, err := os.Stat(filepath.Join(workspace, "skills", "github", ".skill-origin.json")); err != nil { + t.Fatalf("origin metadata missing: %v", err) + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/skills/github", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusOK { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String()) + } + + var detailResp skillDetailResponse + if err := json.Unmarshal(detailRec.Body.Bytes(), &detailResp); err != nil { + t.Fatalf("Unmarshal(detail response) error = %v", err) + } + if detailResp.RegistryURL != server.URL+"/skills/github" { + t.Fatalf("detailResp.RegistryURL = %q, want %q", detailResp.RegistryURL, server.URL+"/skills/github") + } + + searchRec := httptest.NewRecorder() + searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=github&limit=5", nil) + mux.ServeHTTP(searchRec, searchReq) + + if searchRec.Code != http.StatusOK { + t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String()) + } + + var searchResp skillSearchResponse + if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil { + t.Fatalf("Unmarshal(search response) error = %v", err) + } + if len(searchResp.Results) != 1 { + t.Fatalf("search results count = %d, want 1", len(searchResp.Results)) + } + if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "github" { + t.Fatalf("search result should be treated as installed after registry install, got %#v", searchResp.Results[0]) + } +} + +func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + skillDir := filepath.Join(workspace, "skills", "github") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + oldContent := []byte("---\nname: github\ndescription: Existing skill\n---\n# Existing\n") + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), oldContent, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + http.Error(w, "upstream download failed", http.StatusBadGateway) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + Force: true, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String()) + } + + gotContent, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytes.Equal(gotContent, oldContent) { + t.Fatalf("existing skill should remain unchanged, got:\n%s", string(gotContent)) + } +} + +func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + previousPersist := persistSkillOriginMeta + persistSkillOriginMeta = func(targetDir string, meta installedSkillOriginMeta) error { + return errors.New("forced metadata failure") + } + defer func() { + persistSkillOriginMeta = previousPersist + }() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "github") + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed after metadata write failure, stat err=%v", err) + } +} + +func TestHandleInstallSkillSerializesConcurrentRequests(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n", + }) + + downloadStarted := make(chan struct{}, 2) + releaseFirstDownload := make(chan struct{}) + downloadCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + downloadCount++ + downloadStarted <- struct{}{} + if downloadCount == 1 { + <-releaseFirstDownload + } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + type installResult struct { + code int + body string + } + results := make(chan installResult, 2) + startInstall := func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + results <- installResult{ + code: rec.Code, + body: rec.Body.String(), + } + } + + go startInstall() + + select { + case <-downloadStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first install download to start") + } + + go startInstall() + + select { + case <-downloadStarted: + t.Fatal("second install should not reach registry download before the first request completes") + case <-time.After(200 * time.Millisecond): + } + + close(releaseFirstDownload) + + firstResult := <-results + secondResult := <-results + + codes := map[int]int{ + firstResult.code: 1, + secondResult.code: 1, + } + if codes[http.StatusOK] != 1 || codes[http.StatusConflict] != 1 { + t.Fatalf( + "unexpected install results: first=(%d, %q) second=(%d, %q)", + firstResult.code, + firstResult.body, + secondResult.code, + secondResult.body, + ) + } +} + +func TestHandleImportSkillWaitsForConcurrentInstall(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "SKILL.md": "---\nname: github\ndescription: GitHub registry skill\n---\n# GitHub\n", + }) + + downloadStarted := make(chan struct{}, 1) + releaseDownload := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + downloadStarted <- struct{}{} + <-releaseDownload + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + installBody, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + type result struct { + code int + body string + } + installResults := make(chan result, 1) + importResults := make(chan result, 1) + + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + installResults <- result{code: rec.Code, body: rec.Body.String()} + }() + + select { + case <-downloadStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for install download to start") + } + + var importBody bytes.Buffer + writer := multipart.NewWriter(&importBody) + part, err := writer.CreateFormFile("file", "github.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := io.WriteString(part, "# GitHub\n"); err != nil { + t.Fatalf("WriteString() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &importBody) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + importResults <- result{code: rec.Code, body: rec.Body.String()} + }() + + select { + case got := <-importResults: + t.Fatalf("import should wait for the install lock, got early response (%d, %q)", got.code, got.body) + case <-time.After(200 * time.Millisecond): + } + + close(releaseDownload) + + installResult := <-installResults + importResult := <-importResults + + if installResult.code != http.StatusOK { + t.Fatalf("install status = %d, want %d, body=%s", installResult.code, http.StatusOK, installResult.body) + } + if importResult.code != http.StatusConflict { + t.Fatalf("import status = %d, want %d, body=%s", importResult.code, http.StatusConflict, importResult.body) + } +} + +func TestHandleInstallSkillRejectsInvalidArchive(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, loadErr := config.LoadConfig(configPath) + if loadErr != nil { + t.Fatalf("LoadConfig() error = %v", loadErr) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + + zipContent := buildSkillZip(t, map[string]string{ + "README.md": "# Not a skill\n", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/github": + json.NewEncoder(w).Encode(map[string]any{ + "slug": "github", + "displayName": "GitHub", + "summary": "GitHub registry skill", + "latestVersion": map[string]any{ + "version": "1.2.3", + }, + "moderation": map[string]any{ + "isMalwareBlocked": false, + "isSuspicious": false, + }, + }) + case "/api/v1/download": + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipContent) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + body, err := json.Marshal(installSkillRequest{ + Slug: "github", + Registry: "clawhub", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadGateway, rec.Body.String()) + } + + skillDir := filepath.Join(workspace, "skills", "github") + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("invalid installed archive should be removed, stat err=%v", err) + } +} + +func buildSkillZip(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + for name, content := range files { + writer, err := zipWriter.Create(name) + if err != nil { + t.Fatalf("Create(%q) error = %v", name, err) + } + if _, err := io.WriteString(writer, content); err != nil { + t.Fatalf("WriteString(%q) error = %v", name, err) + } + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + return buf.Bytes() +} diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts index 72ccbcfe5..958808afd 100644 --- a/web/frontend/src/api/skills.ts +++ b/web/frontend/src/api/skills.ts @@ -5,22 +5,60 @@ export interface SkillSupportItem { path: string source: "workspace" | "global" | "builtin" | string description: string + origin_kind: "builtin" | "third_party" | "manual" | string + registry_name?: string + registry_url?: string + installed_version?: string + installed_at?: number } export interface SkillDetailResponse extends SkillSupportItem { content: string } +export interface SkillRegistrySearchResult { + score: number + slug: string + display_name: string + summary: string + version: string + registry_name: string + url?: string + installed: boolean + installed_name?: string +} + interface SkillsResponse { skills: SkillSupportItem[] } -interface SkillActionResponse { +export interface SkillSearchResponse { + results: SkillRegistrySearchResult[] + limit: number + offset: number + next_offset?: number + has_more: boolean +} + +type SkillActionResponse = Partial & { status?: string - name?: string - path?: string - source?: string - description?: string +} + +export interface InstallSkillRequest { + slug: string + registry: string + version?: string + force?: boolean +} + +export interface InstallSkillResponse { + status: string + slug: string + registry: string + version: string + summary?: string + is_suspicious?: boolean + skill?: SkillSupportItem } async function request(path: string, options?: RequestInit): Promise { @@ -39,6 +77,29 @@ export async function getSkill(name: string): Promise { return request(`/api/skills/${encodeURIComponent(name)}`) } +export async function searchSkills( + query: string, + limit = 20, + offset = 0, +): Promise { + const params = new URLSearchParams({ + q: query, + limit: String(limit), + offset: String(offset), + }) + return request(`/api/skills/search?${params.toString()}`) +} + +export async function installSkill( + input: InstallSkillRequest, +): Promise { + return request("/api/skills/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }) +} + export async function importSkill(file: File): Promise { const formData = new FormData() formData.set("file", file) @@ -64,15 +125,23 @@ export async function deleteSkill(name: string): Promise { async function extractErrorMessage(res: Response): Promise { try { - const body = (await res.json()) as { - error?: string - errors?: string[] + const raw = await res.text() + if (raw.trim() === "") { + return `API error: ${res.status} ${res.statusText}` } - if (Array.isArray(body.errors) && body.errors.length > 0) { - return body.errors.join("; ") - } - if (typeof body.error === "string" && body.error.trim() !== "") { - return body.error + try { + const body = JSON.parse(raw) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + return body.errors.join("; ") + } + if (typeof body.error === "string" && body.error.trim() !== "") { + return body.error + } + } catch { + return raw.trim() } } catch { // ignore invalid body diff --git a/web/frontend/src/components/agent/hub/hub-page.tsx b/web/frontend/src/components/agent/hub/hub-page.tsx new file mode 100644 index 000000000..69f0be638 --- /dev/null +++ b/web/frontend/src/components/agent/hub/hub-page.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next" + +import { PageHeader } from "@/components/page-header" + +import { ResultsPanel } from "./results-panel" +import { SearchPanel } from "./search-panel" +import { useHubMarketplace } from "./use-hub-marketplace" + +export function HubPage() { + const { t } = useTranslation() + const hub = useHubMarketplace() + + return ( +
+ + +
+
+
+ + + +
+
+
+
+ ) +} diff --git a/web/frontend/src/components/agent/hub/market-skill-card.tsx b/web/frontend/src/components/agent/hub/market-skill-card.tsx new file mode 100644 index 000000000..64493ddf4 --- /dev/null +++ b/web/frontend/src/components/agent/hub/market-skill-card.tsx @@ -0,0 +1,132 @@ +import { + IconCheck, + IconFileInfo, + IconLoader2, + IconPlus, +} from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { + type SkillRegistrySearchResult, + type SkillSupportItem, +} from "@/api/skills" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export function MarketSkillCard({ + result, + canInstall, + installPending, + installedSkill, + onInstall, + onViewInstalled, +}: { + result: SkillRegistrySearchResult + canInstall: boolean + installPending: boolean + installedSkill: SkillSupportItem | null + onInstall: () => void + onViewInstalled: () => void +}) { + const { t } = useTranslation() + + return ( + + {result.installed && ( +
+ )} + +
+
+
+ + {result.display_name || result.slug} + + + {result.registry_name} + + {result.installed ? ( + + {t("pages.agent.skills.marketplace_installed")} + + ) : null} +
+
+ {result.slug} + {result.version ? ( + + {" "} + · v{result.version} + + ) : null} +
+ + {result.summary} + + {result.url ? ( + + ) : null} +
+
+ + {result.installed && installedSkill ? ( + + ) : null} +
+
+
+ {result.installed_name ? ( + +
+ {t("pages.agent.skills.marketplace_installed_hint", { + name: result.installed_name, + })} +
+
+ ) : null} + + ) +} diff --git a/web/frontend/src/components/agent/hub/results-panel.tsx b/web/frontend/src/components/agent/hub/results-panel.tsx new file mode 100644 index 000000000..e2a351955 --- /dev/null +++ b/web/frontend/src/components/agent/hub/results-panel.tsx @@ -0,0 +1,135 @@ +import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { + type SkillRegistrySearchResult, + type SkillSupportItem, +} from "@/api/skills" + +import { MarketSkillCard } from "./market-skill-card" + +export function ResultsPanel({ + canSearchMarketplace, + hasSubmittedQuery, + submittedQuery, + marketResults, + marketSearchError, + isMarketSearchInitialLoading, + isMarketSearchLoadingMore, + canInstallFromMarketplace, + getInstalledSkill, + isInstallPending, + onInstall, + onViewInstalled, +}: { + canSearchMarketplace: boolean + hasSubmittedQuery: boolean + submittedQuery: string + marketResults: SkillRegistrySearchResult[] + marketSearchError: unknown + isMarketSearchInitialLoading: boolean + isMarketSearchLoadingMore: boolean + canInstallFromMarketplace: boolean + getInstalledSkill: (installedName?: string) => SkillSupportItem | null + isInstallPending: (result: SkillRegistrySearchResult) => boolean + onInstall: (result: SkillRegistrySearchResult) => void + onViewInstalled: () => void +}) { + const { t } = useTranslation() + + return ( +
+
+ {canSearchMarketplace && hasSubmittedQuery ? ( +
+
+
+ {t("pages.agent.skills.marketplace_notice_title")} +
+
+ {t("pages.agent.skills.marketplace_notice_body")} +
+
+ + {isMarketSearchInitialLoading ? ( +
+ + + {t("pages.agent.skills.marketplace_loading_results")} + +
+ ) : marketSearchError ? ( +
+
+ + + {marketSearchError instanceof Error + ? marketSearchError.message + : t("pages.agent.skills.marketplace_search_error")} + +
+
+ ) : marketResults.length ? ( +
+
+

+ {t("pages.agent.skills.marketplace_results_title", { + query: submittedQuery, + count: marketResults.length, + })} +

+ + {t("pages.agent.skills.marketplace_results_hint")} + +
+
+ {marketResults.map((result) => ( + onInstall(result)} + onViewInstalled={onViewInstalled} + /> + ))} +
+ {isMarketSearchLoadingMore ? ( +
+ + + {t("pages.agent.skills.marketplace_loading_more")} + +
+ ) : null} +
+ ) : ( +
+ + + {t("pages.agent.skills.marketplace_empty_results", { + query: submittedQuery, + })} + +
+ )} +
+ ) : !canSearchMarketplace ? ( +
+ + {t("pages.agent.skills.marketplace_unavailable")} + +
+ ) : ( +
+ + + {t("pages.agent.skills.marketplace_idle")} + +
+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/hub/search-panel.tsx b/web/frontend/src/components/agent/hub/search-panel.tsx new file mode 100644 index 000000000..875aaad6b --- /dev/null +++ b/web/frontend/src/components/agent/hub/search-panel.tsx @@ -0,0 +1,91 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +import type { UnavailableToolMessage } from "./tool-support" + +export function SearchPanel({ + marketQuery, + canSearchMarketplace, + isMarketSearchInitialLoading, + unavailableToolMessages, + onMarketQueryChange, + onSearchSubmit, +}: { + marketQuery: string + canSearchMarketplace: boolean + isMarketSearchInitialLoading: boolean + unavailableToolMessages: UnavailableToolMessage[] + onMarketQueryChange: (value: string) => void + onSearchSubmit: () => void +}) { + const { t } = useTranslation() + + return ( +
+
+

+ {t("pages.agent.skills.marketplace_title", { + defaultValue: "Discover Skills", + })} +

+

+ {t("pages.agent.skills.marketplace_description")} +

+
+ +
{ + event.preventDefault() + onSearchSubmit() + }} + > +
+ onMarketQueryChange(event.target.value)} + placeholder={t("pages.agent.skills.marketplace_search_placeholder")} + className="border-border/60 bg-background/50 hover:bg-background focus-visible:ring-primary/20 h-12 w-full rounded-full pr-20 pl-5 text-sm shadow-sm backdrop-blur-sm transition-all focus-visible:ring-2 md:min-w-[520px]" + disabled={!canSearchMarketplace} + /> + +
+
+ + {unavailableToolMessages.length ? ( +
+ {unavailableToolMessages.map((item) => ( +
+
{item.label}
+
{item.message}
+
+ ))} +
+ ) : null} +
+ ) +} diff --git a/web/frontend/src/components/agent/hub/tool-support.ts b/web/frontend/src/components/agent/hub/tool-support.ts new file mode 100644 index 000000000..257f9c12a --- /dev/null +++ b/web/frontend/src/components/agent/hub/tool-support.ts @@ -0,0 +1,54 @@ +import type { TFunction } from "i18next" + +import type { ToolSupportItem } from "@/api/tools" + +type MarketplaceTool = Pick | undefined + +export interface UnavailableToolMessage { + key: "search" | "install" + label: string + message: string +} + +export function buildUnavailableToolMessages({ + searchTool, + installTool, + t, +}: { + searchTool: MarketplaceTool + installTool: MarketplaceTool + t: TFunction +}): UnavailableToolMessage[] { + const searchMessage = getToolSupportMessage(searchTool, t) + const installMessage = getToolSupportMessage(installTool, t) + + return [ + searchMessage + ? { + key: "search", + label: t("pages.agent.skills.marketplace_search_status"), + message: searchMessage, + } + : null, + installMessage + ? { + key: "install", + label: t("pages.agent.skills.marketplace_install_status"), + message: installMessage, + } + : null, + ].filter((item): item is UnavailableToolMessage => Boolean(item)) +} + +function getToolSupportMessage( + tool: MarketplaceTool, + t: TFunction, +): string | null { + if (!tool || tool.status === "enabled") { + return null + } + if (tool.reason_code) { + return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}` + } + return t("pages.agent.skills.marketplace_status_disabled") +} diff --git a/web/frontend/src/components/agent/hub/use-hub-marketplace.ts b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts new file mode 100644 index 000000000..07e8c36fb --- /dev/null +++ b/web/frontend/src/components/agent/hub/use-hub-marketplace.ts @@ -0,0 +1,211 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { useEffect, useRef, useState, type UIEvent } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + getSkills, + installSkill, + searchSkills, + type SkillSearchResponse, + type SkillRegistrySearchResult, + type SkillSupportItem, +} from "@/api/skills" +import { getTools } from "@/api/tools" + +import { buildUnavailableToolMessages } from "./tool-support" + +const MARKET_SEARCH_LIMIT = 20 + +export function useHubMarketplace() { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const isLoadMoreLockedRef = useRef(false) + + const [marketQuery, setMarketQuery] = useState("") + const [submittedMarketQuery, setSubmittedMarketQuery] = useState("") + + const { data: skillsData } = useQuery({ + queryKey: ["skills"], + queryFn: getSkills, + }) + const { data: toolsData } = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + + const findSkillsTool = toolsData?.tools.find( + (tool) => tool.name === "find_skills", + ) + const installSkillTool = toolsData?.tools.find( + (tool) => tool.name === "install_skill", + ) + const canSearchMarketplace = findSkillsTool?.status === "enabled" + const canInstallFromMarketplace = installSkillTool?.status === "enabled" + const hasSubmittedQuery = submittedMarketQuery.trim() !== "" + const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery + + const { + data: marketSearchData, + isPending: isMarketSearchPending, + isFetching: isMarketSearchFetching, + isFetchingNextPage, + error: marketSearchError, + hasNextPage, + fetchNextPage, + refetch: refetchMarketSearch, + } = useInfiniteQuery({ + queryKey: ["skills-marketplace", submittedMarketQuery], + initialPageParam: 0, + queryFn: ({ pageParam }) => + searchSkills( + submittedMarketQuery, + MARKET_SEARCH_LIMIT, + Number(pageParam) || 0, + ), + getNextPageParam: (lastPage: SkillSearchResponse) => + lastPage.has_more ? lastPage.next_offset ?? undefined : undefined, + enabled: isMarketSearchActive, + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }) + + const installMutation = useMutation({ + mutationFn: installSkill, + onSuccess: (response) => { + toast.success( + t("pages.agent.skills.install_success", { + name: response.skill?.name ?? response.slug, + }), + ) + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.install_error"), + ) + }, + }) + + const allSkills = skillsData?.skills ?? [] + const workspaceSkillsByName = new Map( + allSkills + .filter((skill) => skill.source === "workspace") + .map((skill) => [skill.name, skill] as const), + ) + const marketResults = + marketSearchData?.pages.flatMap((page) => page.results) ?? [] + const hasMoreMarketResults = hasNextPage ?? false + const isMarketSearchInitialLoading = + isMarketSearchActive && + !marketSearchData && + (isMarketSearchPending || isMarketSearchFetching) + const isMarketSearchLoadingMore = + isMarketSearchActive && + Boolean(marketSearchData) && + isFetchingNextPage + const installPendingKey = + installMutation.isPending && installMutation.variables + ? `${installMutation.variables.registry}:${installMutation.variables.slug}` + : null + + const unavailableToolMessages = buildUnavailableToolMessages({ + searchTool: findSkillsTool, + installTool: installSkillTool, + t, + }) + + useEffect(() => { + if (!isFetchingNextPage) { + isLoadMoreLockedRef.current = false + } + }, [isFetchingNextPage]) + + const handleSearchSubmit = () => { + const nextQuery = marketQuery.trim() + if (!canSearchMarketplace || nextQuery === "") { + return + } + + isLoadMoreLockedRef.current = false + if (nextQuery === submittedMarketQuery) { + void refetchMarketSearch() + return + } + + setSubmittedMarketQuery(nextQuery) + } + + const handleInstall = (result: SkillRegistrySearchResult) => { + installMutation.mutate({ + slug: result.slug, + registry: result.registry_name, + version: result.version || undefined, + }) + } + + const handleViewInstalled = () => { + void navigate({ to: "/agent/skills" }) + } + + const handleScroll = (event: UIEvent) => { + if ( + !isMarketSearchActive || + !hasMoreMarketResults || + isFetchingNextPage || + isLoadMoreLockedRef.current + ) { + return + } + + const node = event.currentTarget + const remaining = node.scrollHeight - node.scrollTop - node.clientHeight + if (remaining > 240) { + return + } + + isLoadMoreLockedRef.current = true + void fetchNextPage() + } + + const getInstalledSkill = (installedName?: string): SkillSupportItem | null => { + if (!installedName) { + return null + } + return workspaceSkillsByName.get(installedName) ?? null + } + + const isInstallPending = (result: SkillRegistrySearchResult) => + installPendingKey === `${result.registry_name}:${result.slug}` + + return { + marketQuery, + submittedMarketQuery, + canSearchMarketplace, + canInstallFromMarketplace, + marketResults, + marketSearchError, + unavailableToolMessages, + hasSubmittedQuery, + isMarketSearchInitialLoading, + isMarketSearchLoadingMore, + setMarketQuery, + handleSearchSubmit, + handleInstall, + handleViewInstalled, + handleScroll, + getInstalledSkill, + isInstallPending, + } +} diff --git a/web/frontend/src/components/agent/skills/delete-dialog.tsx b/web/frontend/src/components/agent/skills/delete-dialog.tsx new file mode 100644 index 000000000..3dbeed342 --- /dev/null +++ b/web/frontend/src/components/agent/skills/delete-dialog.tsx @@ -0,0 +1,65 @@ +import type { SkillSupportItem } from "@/api/skills" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { IconLoader2, IconTrash } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +interface DeleteDialogProps { + open: boolean + skillPendingDelete: SkillSupportItem | null + isDeletePending: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void +} + +export function DeleteDialog({ + open, + skillPendingDelete, + isDeletePending, + onOpenChange, + onConfirm, +}: DeleteDialogProps) { + const { t } = useTranslation() + + return ( + + + + + {t("pages.agent.skills.delete_title")} + + + {t("pages.agent.skills.delete_description", { + name: skillPendingDelete?.name, + })} + + + + + {t("common.cancel")} + + + {isDeletePending ? ( + + ) : ( + + )} + {t("pages.agent.skills.delete_confirm")} + + + + + ) +} diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx new file mode 100644 index 000000000..699366bf5 --- /dev/null +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -0,0 +1,249 @@ +import { + IconFileCode, + IconSparkles, + IconWorld, + IconX, +} from "@tabler/icons-react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" +import ReactMarkdown from "react-markdown" +import rehypeRaw from "rehype-raw" +import rehypeSanitize from "rehype-sanitize" +import remarkGfm from "remark-gfm" + +import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/lib/utils" + +import { OriginBadge } from "./origin-badge" +import { + getOriginLabel, + getSkillOriginKind, +} from "./origin-utils" +import type { SkillDetailView } from "./types" + +const DETAIL_VIEWS = [ + "preview", + "raw", + "meta", +] as const satisfies SkillDetailView[] + +interface DetailSheetProps { + open: boolean + selectedSkill: SkillSupportItem | null + selectedSkillDetail?: SkillDetailResponse + isLoading: boolean + error: unknown + detailView: SkillDetailView + onDetailViewChange: (view: SkillDetailView) => void + onOpenChange: (open: boolean) => void +} + +export function DetailSheet({ + open, + selectedSkill, + selectedSkillDetail, + isLoading, + error, + detailView, + onDetailViewChange, + onOpenChange, +}: DetailSheetProps) { + const { t } = useTranslation() + + const activeSkillDetail = selectedSkillDetail ?? selectedSkill + const activeSkillOrigin = activeSkillDetail + ? getSkillOriginKind(activeSkillDetail) + : null + const detailLineCount = selectedSkillDetail + ? selectedSkillDetail.content.split("\n").length + : 0 + const detailCharacterCount = selectedSkillDetail?.content.length ?? 0 + + return ( + + + +
+
+ {activeSkillDetail?.origin_kind === "builtin" ? ( + + ) : activeSkillDetail?.registry_name ? ( + + ) : ( + + )} +
+
+ + {activeSkillDetail?.name || t("pages.agent.skills.viewer_title")} + + + {activeSkillDetail?.description || + t("pages.agent.skills.viewer_description")} + +
+
+
+ +
+ + + ) +} + +function MetadataItem({ + label, + value, + mono = false, +}: { + label: string + value: ReactNode + mono?: boolean +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/filter-bar.tsx b/web/frontend/src/components/agent/skills/filter-bar.tsx new file mode 100644 index 000000000..303fd6f60 --- /dev/null +++ b/web/frontend/src/components/agent/skills/filter-bar.tsx @@ -0,0 +1,136 @@ +import { + IconLayoutGrid, + IconLayoutList, + IconSearch, +} from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" + +import { getOriginLabel } from "./origin-utils" +import type { SkillLayoutMode, SkillSortOption } from "./types" + +interface FilterBarProps { + searchQuery: string + sourceFilter: string + availableOrigins: string[] + sortOrder: SkillSortOption + layoutMode: SkillLayoutMode + onSearchQueryChange: (value: string) => void + onSourceFilterChange: (value: string) => void + onSortOrderChange: (value: SkillSortOption) => void + onLayoutModeChange: (value: SkillLayoutMode) => void +} + +export function FilterBar({ + searchQuery, + sourceFilter, + availableOrigins, + sortOrder, + layoutMode, + onSearchQueryChange, + onSourceFilterChange, + onSortOrderChange, + onLayoutModeChange, +}: FilterBarProps) { + const { t } = useTranslation() + + return ( +
+
+ + onSearchQueryChange(event.target.value)} + placeholder={t("pages.agent.skills.search_placeholder")} + className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1" + /> +
+ +
+ + + +
+ + + +
+ +
+ + +
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/import-dialog.tsx b/web/frontend/src/components/agent/skills/import-dialog.tsx new file mode 100644 index 000000000..21f4827e3 --- /dev/null +++ b/web/frontend/src/components/agent/skills/import-dialog.tsx @@ -0,0 +1,160 @@ +import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react" +import type { DragEvent } from "react" +import { useTranslation } from "react-i18next" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { cn } from "@/lib/utils" + +interface ImportDialogProps { + open: boolean + isImportPending: boolean + isDragActive: boolean + onOpenChange: (open: boolean) => void + onImportClick: () => void + onDragEnter: (event: DragEvent) => void + onDragLeave: (event: DragEvent) => void + onDrop: (event: DragEvent) => void +} + +export function ImportDialog({ + open, + isImportPending, + isDragActive, + onOpenChange, + onImportClick, + onDragEnter, + onDragLeave, + onDrop, +}: ImportDialogProps) { + const { t } = useTranslation() + + return ( + { + if (!isImportPending) { + onOpenChange(nextOpen) + } + }} + > + +
+ + + + + {t("pages.agent.skills.dropzone_title")} + + + {t("pages.agent.skills.dropzone_description")} + + +
+ + +
+
+ ) +} + +function SkillImportPanel({ + isDragActive, + isImportPending, + onDragEnter, + onDragLeave, + onDrop, + onImportClick, +}: { + isDragActive: boolean + isImportPending: boolean + onDragEnter: (event: DragEvent) => void + onDragLeave: (event: DragEvent) => void + onDrop: (event: DragEvent) => void + onImportClick: () => void +}) { + const { t } = useTranslation() + + return ( +
+
{ + if (!isImportPending) { + onImportClick() + } + }} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDragOver={(event) => event.preventDefault()} + onDrop={onDrop} + > +
+ +
+
+
+ {isDragActive + ? t("pages.agent.skills.dropzone_active") + : t("pages.agent.skills.dropzone_label")} +
+

+ {isDragActive + ? t("pages.agent.skills.dropzone_release") + : t("pages.agent.skills.import_constraints")} +

+
+ +
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/origin-badge.tsx b/web/frontend/src/components/agent/skills/origin-badge.tsx new file mode 100644 index 000000000..0b7bf4391 --- /dev/null +++ b/web/frontend/src/components/agent/skills/origin-badge.tsx @@ -0,0 +1,46 @@ +import { + IconFileCode, + IconFolder, + IconSparkles, + IconWorld, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +import { getOriginBadgeClasses } from "./origin-utils" + +export function OriginBadge({ + origin, + label, +}: { + origin: string + label: string +}) { + return ( + + + {label} + + ) +} + +export function OriginIcon({ origin }: { origin: string }) { + if (origin === "builtin") { + return + } + if (origin === "third_party") { + return + } + if (origin === "manual") { + return + } + if (origin === "all") { + return + } + return +} diff --git a/web/frontend/src/components/agent/skills/origin-utils.ts b/web/frontend/src/components/agent/skills/origin-utils.ts new file mode 100644 index 000000000..6163f7bf7 --- /dev/null +++ b/web/frontend/src/components/agent/skills/origin-utils.ts @@ -0,0 +1,86 @@ +import type { TFunction } from "i18next" + +import type { SkillSupportItem } from "@/api/skills" + +import type { SkillSortOption } from "./types" + +const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"] + +export function compareSkills( + left: SkillSupportItem, + right: SkillSupportItem, + sortOrder: SkillSortOption, +) { + if (sortOrder === "source") { + const sourceDelta = compareOriginOrder( + getSkillOriginKind(left), + getSkillOriginKind(right), + ) + if (sourceDelta !== 0) return sourceDelta + return left.name.localeCompare(right.name) + } + + if (sortOrder === "name-desc") { + return right.name.localeCompare(left.name) + } + + return left.name.localeCompare(right.name) +} + +export function sortOrigins(origins: string[]) { + return [...origins].sort(compareOriginOrder) +} + +export function getSkillOriginKind(skill: SkillSupportItem) { + const origin = skill.origin_kind || skill.source + return origin === "global" ? "builtin" : origin +} + +export function getOriginLabel(origin: string, t: TFunction) { + if (origin === "builtin" || origin === "third_party" || origin === "manual") { + return t(`pages.agent.skills.origin.${origin}`) + } + if (origin === "all") { + return t("pages.agent.skills.origin.all") + } + return origin +} + +export function getOriginAccentClasses(origin: string) { + if (origin === "manual") { + return "bg-emerald-100 text-emerald-700" + } + if (origin === "third_party") { + return "bg-sky-100 text-sky-700" + } + if (origin === "builtin") { + return "bg-amber-100 text-amber-700" + } + return "bg-muted text-muted-foreground" +} + +export function getOriginBadgeClasses(origin: string) { + if (origin === "manual") { + return "bg-emerald-100 text-emerald-700" + } + if (origin === "third_party") { + return "bg-sky-100 text-sky-700" + } + if (origin === "builtin") { + return "bg-amber-100 text-amber-700" + } + return "bg-muted text-muted-foreground" +} + +function compareOriginOrder(left: string, right: string) { + const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left) + const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right) + + if (leftIndex !== -1 || rightIndex !== -1) { + if (leftIndex === -1) return 1 + if (rightIndex === -1) return -1 + return leftIndex - rightIndex + } + + return left.localeCompare(right) +} diff --git a/web/frontend/src/components/agent/skills/page-skeleton.tsx b/web/frontend/src/components/agent/skills/page-skeleton.tsx new file mode 100644 index 000000000..73df6fcdf --- /dev/null +++ b/web/frontend/src/components/agent/skills/page-skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export function PageSkeleton() { + return ( +
+
+ {[1, 2, 3, 4].map((index) => ( + + ))} +
+
+
+ +
+ +
+ {[1, 2, 3, 4].map((index) => ( + + ))} +
+
+
+ ) +} diff --git a/web/frontend/src/components/agent/skills/skill-card.tsx b/web/frontend/src/components/agent/skills/skill-card.tsx new file mode 100644 index 000000000..15bdc2c63 --- /dev/null +++ b/web/frontend/src/components/agent/skills/skill-card.tsx @@ -0,0 +1,84 @@ +import { IconFileInfo, IconTrash } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { SkillSupportItem } from "@/api/skills" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +interface SkillCardProps { + skill: SkillSupportItem + onView: () => void + onDelete: () => void +} + +export function SkillCard({ skill, onView, onDelete }: SkillCardProps) { + const { t } = useTranslation() + + return ( + +
+ +
+
+
+ + {skill.name} + + {skill.registry_name ? ( + + {skill.registry_name} + + ) : null} +
+ + {skill.description || t("pages.agent.skills.no_description")} + +
+
+ + {skill.source === "workspace" ? ( + + ) : null} +
+
+
+ + {skill.registry_url ? ( +
+ {skill.registry_url} + + ) : null} + + + ) +} diff --git a/web/frontend/src/components/agent/skills/skills-list.tsx b/web/frontend/src/components/agent/skills/skills-list.tsx new file mode 100644 index 000000000..6a2bb92ed --- /dev/null +++ b/web/frontend/src/components/agent/skills/skills-list.tsx @@ -0,0 +1,86 @@ +import { IconSearch } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { SkillSupportItem } from "@/api/skills" + +import { OriginBadge } from "./origin-badge" +import { getOriginLabel } from "./origin-utils" +import { SkillCard } from "./skill-card" +import type { SkillGroupSection, SkillLayoutMode } from "./types" + +interface SkillsListProps { + sortedSkills: SkillSupportItem[] + groupedSkills: SkillGroupSection[] + layoutMode: SkillLayoutMode + sourceFilter: string + hasActiveFilters: boolean + onViewSkill: (skill: SkillSupportItem) => void + onDeleteSkill: (skill: SkillSupportItem) => void +} + +export function SkillsList({ + sortedSkills, + groupedSkills, + layoutMode, + sourceFilter, + hasActiveFilters, + onViewSkill, + onDeleteSkill, +}: SkillsListProps) { + const { t } = useTranslation() + + if (!sortedSkills.length) { + return ( +
+
+ +
+

+ {hasActiveFilters + ? t("pages.agent.skills.no_results") + : t("pages.agent.skills.empty")} +

+
+ ) + } + + if (layoutMode === "grouped" && sourceFilter === "all") { + return ( +
+ {groupedSkills.map((section) => ( +
+
+ +
+
+ {section.skills.map((skill) => ( + onViewSkill(skill)} + onDelete={() => onDeleteSkill(skill)} + /> + ))} +
+
+ ))} +
+ ) + } + + return ( +
+ {sortedSkills.map((skill) => ( + onViewSkill(skill)} + onDelete={() => onDeleteSkill(skill)} + /> + ))} +
+ ) +} diff --git a/web/frontend/src/components/agent/skills/skills-page.tsx b/web/frontend/src/components/agent/skills/skills-page.tsx new file mode 100644 index 000000000..d9b5a7cd1 --- /dev/null +++ b/web/frontend/src/components/agent/skills/skills-page.tsx @@ -0,0 +1,160 @@ +import { IconLoader2, IconPlus } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" + +import { DeleteDialog } from "./delete-dialog" +import { DetailSheet } from "./detail-sheet" +import { FilterBar } from "./filter-bar" +import { ImportDialog } from "./import-dialog" +import { PageSkeleton } from "./page-skeleton" +import { SkillsList } from "./skills-list" +import { Stats } from "./stats" +import { useSkillsPage } from "./use-skills-page" + +export function SkillsPage() { + const { t } = useTranslation() + const { + searchQuery, + sourceFilter, + sortOrder, + layoutMode, + detailView, + isDragActive, + isImportDialogOpen, + selectedSkill, + skillPendingDelete, + availableOrigins, + groupedSkills, + stats, + sortedSkills, + hasActiveFilters, + importInputRef, + selectedSkillDetail, + skillsError, + skillDetailError, + isLoading, + isSkillDetailLoading, + isImportPending, + isDeletePending, + setSearchQuery, + setSourceFilter, + setSortOrder, + setLayoutMode, + setDetailView, + openImportDialog, + handleViewSkill, + handleRequestDelete, + handleConfirmDelete, + handleImportClick, + handleImportFileChange, + handleDropZoneDragEnter, + handleDropZoneDragLeave, + handleDropZoneDrop, + handleDetailSheetOpenChange, + handleImportDialogOpenChange, + handleDeleteDialogOpenChange, + } = useSkillsPage() + + return ( +
+ + + + + } + /> + +
+
+ {isLoading ? ( + + ) : skillsError ? ( +
+ {t("pages.agent.load_error")} +
+ ) : ( +
+ + +
+ +
+ + +
+ )} +
+
+ + + + + + +
+ ) +} diff --git a/web/frontend/src/components/agent/skills/stats.tsx b/web/frontend/src/components/agent/skills/stats.tsx new file mode 100644 index 000000000..c718fc3be --- /dev/null +++ b/web/frontend/src/components/agent/skills/stats.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +import { OriginIcon } from "./origin-badge" +import { getOriginAccentClasses } from "./origin-utils" +import type { SkillStatItem } from "./types" + +export function Stats({ stats }: { stats: SkillStatItem[] }) { + return ( +
+ {stats.map((stat) => ( + + +
+
+ {stat.label} +
+
+ {stat.count} +
+
+
+ +
+
+
+ ))} +
+ ) +} diff --git a/web/frontend/src/components/agent/skills/types.ts b/web/frontend/src/components/agent/skills/types.ts new file mode 100644 index 000000000..44509854c --- /dev/null +++ b/web/frontend/src/components/agent/skills/types.ts @@ -0,0 +1,17 @@ +import type { SkillSupportItem } from "@/api/skills" + +export type SkillSortOption = "name-asc" | "name-desc" | "source" +export type SkillLayoutMode = "grouped" | "grid" +export type SkillDetailView = "preview" | "raw" | "meta" + +export interface SkillGroupSection { + origin: string + skills: SkillSupportItem[] +} + +export interface SkillStatItem { + key: string + origin: string + label: string + count: number +} diff --git a/web/frontend/src/components/agent/skills/use-skills-page.ts b/web/frontend/src/components/agent/skills/use-skills-page.ts new file mode 100644 index 000000000..ffe9fc90c --- /dev/null +++ b/web/frontend/src/components/agent/skills/use-skills-page.ts @@ -0,0 +1,336 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + type ChangeEvent, + type DragEvent, + startTransition, + useDeferredValue, + useMemo, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type SkillSupportItem, + deleteSkill, + getSkill, + getSkills, + importSkill, +} from "@/api/skills" + +import { + compareSkills, + getOriginLabel, + getSkillOriginKind, + sortOrigins, +} from "./origin-utils" +import type { + SkillDetailView, + SkillGroupSection, + SkillLayoutMode, + SkillSortOption, + SkillStatItem, +} from "./types" + +const MAX_IMPORT_FILE_SIZE = 1 << 20 + +export function useSkillsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const importInputRef = useRef(null) + const dragDepthRef = useRef(0) + + const [searchQuery, setSearchQuery] = useState("") + const deferredSearchQuery = useDeferredValue(searchQuery) + const [sourceFilter, setSourceFilter] = useState("all") + const [sortOrder, setSortOrder] = useState("name-asc") + const [layoutMode, setLayoutMode] = useState("grouped") + const [detailView, setDetailView] = useState("preview") + const [isDragActive, setIsDragActive] = useState(false) + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) + const [selectedSkill, setSelectedSkill] = useState( + null, + ) + const [skillPendingDelete, setSkillPendingDelete] = + useState(null) + + const skillsQuery = useQuery({ + queryKey: ["skills"], + queryFn: getSkills, + }) + + const skillDetailQuery = useQuery({ + queryKey: ["skills", selectedSkill?.name], + queryFn: () => getSkill(selectedSkill!.name), + enabled: selectedSkill !== null, + }) + + const importMutation = useMutation({ + mutationFn: async (file: File) => importSkill(file), + onSuccess: (importedSkill) => { + toast.success(t("pages.agent.skills.import_success")) + startTransition(() => { + setIsImportDialogOpen(false) + setDetailView("preview") + if (importedSkill.name) { + setSelectedSkill({ + name: importedSkill.name, + path: importedSkill.path ?? "", + source: importedSkill.source ?? "workspace", + description: importedSkill.description ?? "", + origin_kind: importedSkill.origin_kind ?? "manual", + registry_name: importedSkill.registry_name, + registry_url: importedSkill.registry_url, + installed_version: importedSkill.installed_version, + installed_at: importedSkill.installed_at, + }) + } + }) + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.import_error"), + ) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: async (name: string) => deleteSkill(name), + onSuccess: (_, deletedName) => { + toast.success(t("pages.agent.skills.delete_success")) + setSkillPendingDelete(null) + if ( + selectedSkill?.name === deletedName && + selectedSkill.source === "workspace" + ) { + setSelectedSkill(null) + } + void queryClient.invalidateQueries({ queryKey: ["skills"] }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.skills.delete_error"), + ) + }, + }) + + const allSkills = useMemo( + () => skillsQuery.data?.skills ?? [], + [skillsQuery.data?.skills], + ) + const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() + + const availableOrigins = useMemo( + () => + sortOrigins([ + ...new Set(allSkills.map((skill) => getSkillOriginKind(skill))), + ]), + [allSkills], + ) + + const filteredSkills = useMemo(() => { + return allSkills.filter((skill) => { + const matchesSource = + sourceFilter === "all" + ? true + : getSkillOriginKind(skill) === sourceFilter + if (!matchesSource) return false + if (normalizedSearchQuery === "") return true + + const searchTarget = + `${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase() + return searchTarget.includes(normalizedSearchQuery) + }) + }, [allSkills, normalizedSearchQuery, sourceFilter]) + + const sortedSkills = useMemo( + () => [...filteredSkills].sort((left, right) => compareSkills(left, right, sortOrder)), + [filteredSkills, sortOrder], + ) + + const groupedSkills = useMemo( + () => + availableOrigins + .map((origin) => ({ + origin, + skills: sortedSkills.filter( + (skill) => getSkillOriginKind(skill) === origin, + ), + })) + .filter((section) => section.skills.length > 0), + [availableOrigins, sortedSkills], + ) + + const stats = useMemo( + () => [ + { + key: "all", + origin: "all", + label: t("pages.agent.skills.summary.total"), + count: allSkills.length, + }, + ...availableOrigins.map((origin) => ({ + key: origin, + origin, + label: getOriginLabel(origin, t), + count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin) + .length, + })), + ], + [allSkills, availableOrigins, t], + ) + + const hasActiveFilters = + normalizedSearchQuery !== "" || sourceFilter !== "all" + + const handleImportClick = () => { + importInputRef.current?.click() + } + + const handleViewSkill = (skill: SkillSupportItem) => { + setDetailView("preview") + setSelectedSkill(skill) + } + + const handleRequestDelete = (skill: SkillSupportItem) => { + setSkillPendingDelete(skill) + } + + const handleConfirmDelete = () => { + if (skillPendingDelete) { + deleteMutation.mutate(skillPendingDelete.name) + } + } + + const handleDetailSheetOpenChange = (open: boolean) => { + if (!open) { + setSelectedSkill(null) + } + } + + const handleImportDialogOpenChange = (open: boolean) => { + if (!importMutation.isPending) { + setIsImportDialogOpen(open) + } + } + + const handleDeleteDialogOpenChange = (open: boolean) => { + if (!open) { + setSkillPendingDelete(null) + } + } + + const validateImportFile = (file: File) => { + const fileName = file.name.toLowerCase() + const isMarkdownFile = + fileName.endsWith(".md") || + file.type === "text/markdown" || + file.type === "text/plain" || + file.type === "" + const isZipFile = + fileName.endsWith(".zip") || + file.type === "application/zip" || + file.type === "application/x-zip-compressed" + + if (!isMarkdownFile && !isZipFile) { + return t("pages.agent.skills.import_invalid_type") + } + + if (file.size > MAX_IMPORT_FILE_SIZE) { + return t("pages.agent.skills.import_invalid_size") + } + + return null + } + + const handleImportFile = (file: File) => { + const validationMessage = validateImportFile(file) + if (validationMessage) { + toast.error(validationMessage) + return + } + importMutation.mutate(file) + } + + const handleImportFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + handleImportFile(file) + event.target.value = "" + } + + const resetDragState = () => { + dragDepthRef.current = 0 + setIsDragActive(false) + } + + const handleDropZoneDragEnter = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current += 1 + setIsDragActive(true) + } + + const handleDropZoneDragLeave = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) { + setIsDragActive(false) + } + } + + const handleDropZoneDrop = (event: DragEvent) => { + event.preventDefault() + const file = event.dataTransfer.files?.[0] + resetDragState() + if (!file) return + handleImportFile(file) + } + + return { + searchQuery, + sourceFilter, + sortOrder, + layoutMode, + detailView, + isDragActive, + isImportDialogOpen, + selectedSkill, + skillPendingDelete, + availableOrigins, + groupedSkills, + stats, + sortedSkills, + hasActiveFilters, + importInputRef, + selectedSkillDetail: skillDetailQuery.data, + skillsError: skillsQuery.error, + skillDetailError: skillDetailQuery.error, + isLoading: skillsQuery.isLoading, + isSkillDetailLoading: skillDetailQuery.isLoading, + isImportPending: importMutation.isPending, + isDeletePending: deleteMutation.isPending, + setSearchQuery, + setSourceFilter, + setSortOrder, + setLayoutMode, + setDetailView, + openImportDialog: () => setIsImportDialogOpen(true), + handleViewSkill, + handleRequestDelete, + handleConfirmDelete, + handleImportClick, + handleImportFileChange, + handleDropZoneDragEnter, + handleDropZoneDragLeave, + handleDropZoneDrop, + handleDetailSheetOpenChange, + handleImportDialogOpenChange, + handleDeleteDialogOpenChange, + } +} diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx new file mode 100644 index 000000000..034d21649 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -0,0 +1,288 @@ +import { IconSearch } from "@tabler/icons-react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools" +import { PageHeader } from "@/components/page-header" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { refreshGatewayState } from "@/store/gateway" + +export function ToolsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data, isLoading, error } = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + + const [searchQuery, setSearchQuery] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + + const toggleMutation = useMutation({ + mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => + setToolEnabled(name, enabled), + onSuccess: (_, variables) => { + toast.success( + variables.enabled + ? t("pages.agent.tools.enable_success") + : t("pages.agent.tools.disable_success"), + ) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + void refreshGatewayState({ force: true }) + }, + onError: (err) => { + toast.error( + err instanceof Error + ? err.message + : t("pages.agent.tools.toggle_error"), + ) + }, + }) + + // Filter and group tools + const { groupedTools, totalFilteredCount } = useMemo(() => { + if (!data) return { groupedTools: [], totalFilteredCount: 0 } + + let count = 0 + const buckets = new Map() + + for (const item of data.tools) { + // Apply status filter + if (statusFilter !== "all" && item.status !== statusFilter) continue + + // Apply search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase() + const matchesName = item.name.toLowerCase().includes(query) + const matchesDesc = (item.description || "") + .toLowerCase() + .includes(query) + if (!matchesName && !matchesDesc) continue + } + + count++ + const list = buckets.get(item.category) ?? [] + list.push(item) + buckets.set(item.category, list) + } + + return { + groupedTools: Array.from(buckets.entries()), + totalFilteredCount: count, + } + }, [data, searchQuery, statusFilter]) + + return ( +
+ + +
+
+ {/* Header & Description */} +
+ {/* Filters Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+
+ + {/* Content Area */} + {error ? ( + + +

+ {t("pages.agent.load_error")} +

+
+
+ ) : isLoading ? ( + // Skeleton Loading State +
+ {[1, 2].map((groupIndex) => ( +
+ +
+ {[1, 2, 3, 4].map((itemIndex) => ( + + + + + + + + + + + ))} +
+
+ ))} +
+ ) : totalFilteredCount === 0 ? ( + // Empty State + + +
+ +
+

+ {data?.tools.length === 0 + ? t("pages.agent.tools.empty") + : t("pages.agent.tools.no_results")} +

+ {data?.tools.length !== 0 && ( +

+ Try adjusting your search criteria or status filters. +

+ )} +
+
+ ) : ( + // Tool Categories list +
+ {groupedTools.map(([category, items]) => ( +
+

+ {t(`pages.agent.tools.categories.${category}`)} +

+
+ {items.map((tool) => { + const reasonText = tool.reason_code + ? t(`pages.agent.tools.reasons.${tool.reason_code}`) + : "" + const isPending = + toggleMutation.isPending && + toggleMutation.variables?.name === tool.name + const isEnabled = tool.status === "enabled" + const isDisabled = tool.status === "disabled" + const isBlocked = tool.status === "blocked" + + return ( + + +
+
+
+ + {tool.name} + + +
+ + {tool.description} + +
+
+ + toggleMutation.mutate({ + name: tool.name, + enabled: checked, + }) + } + /> +
+
+
+ {reasonText && ( + +
+ {reasonText} +
+
+ )} +
+ ) + })} +
+
+ ))} +
+ )} +
+
+
+ ) +} + +function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) { + const { t } = useTranslation() + + return ( + + {t(`pages.agent.tools.status.${status}`)} + + ) +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 1ba255693..dea43197c 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { IconKey, IconListDetails, IconMessageCircle, + IconSearch, IconSettings, IconSparkles, IconTools, @@ -24,14 +25,15 @@ import { import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, - SidebarFooter, SidebarMenuItem, SidebarRail, + useSidebar, } from "@/components/ui/sidebar" import { useSidebarChannels } from "@/hooks/use-sidebar-channels" @@ -71,6 +73,7 @@ const baseNavGroups: Omit[] = [ export function AppSidebar({ ...props }: React.ComponentProps) { const routerState = useRouterState() const { i18n, t } = useTranslation() + const { isMobile, setOpenMobile } = useSidebar() const currentPath = routerState.location.pathname const { channelItems, @@ -88,6 +91,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }) const versionText = versionInfo?.version ?? t("footer.version_unknown") + const handleNavItemClick = React.useCallback(() => { + if (isMobile) { + setOpenMobile(false) + } + }, [isMobile, setOpenMobile]) const navGroups: NavGroup[] = React.useMemo(() => { return [ @@ -133,6 +141,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { { ...baseNavGroups[2], items: [ + { + title: "navigation.hub", + url: "/agent/hub", + icon: IconSearch, + translateTitle: true, + }, { title: "navigation.skills", url: "/agent/skills", @@ -199,7 +213,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) { @@ -246,7 +263,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ))} - +
{t("footer.version")}:{" "} diff --git a/web/frontend/src/components/skills/skills-page.tsx b/web/frontend/src/components/skills/skills-page.tsx deleted file mode 100644 index 7e0d66d47..000000000 --- a/web/frontend/src/components/skills/skills-page.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { - IconFileInfo, - IconLoader2, - IconPlus, - IconTrash, -} from "@tabler/icons-react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { type ChangeEvent, useRef, useState } from "react" -import { useTranslation } from "react-i18next" -import ReactMarkdown from "react-markdown" -import rehypeRaw from "rehype-raw" -import rehypeSanitize from "rehype-sanitize" -import remarkGfm from "remark-gfm" -import { toast } from "sonner" - -import { - type SkillSupportItem, - deleteSkill, - getSkill, - getSkills, - importSkill, -} from "@/api/skills" -import { PageHeader } from "@/components/page-header" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" - -export function SkillsPage() { - const { t } = useTranslation() - const queryClient = useQueryClient() - const importInputRef = useRef(null) - const [selectedSkill, setSelectedSkill] = useState( - null, - ) - const [skillPendingDelete, setSkillPendingDelete] = - useState(null) - - const { data, isLoading, error } = useQuery({ - queryKey: ["skills"], - queryFn: getSkills, - }) - const { - data: selectedSkillDetail, - isLoading: isSkillDetailLoading, - error: skillDetailError, - } = useQuery({ - queryKey: ["skills", selectedSkill?.name], - queryFn: () => getSkill(selectedSkill!.name), - enabled: selectedSkill !== null, - }) - - const importMutation = useMutation({ - mutationFn: async (file: File) => importSkill(file), - onSuccess: () => { - toast.success(t("pages.agent.skills.import_success")) - void queryClient.invalidateQueries({ queryKey: ["skills"] }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.skills.import_error"), - ) - }, - }) - - const deleteMutation = useMutation({ - mutationFn: async (name: string) => deleteSkill(name), - onSuccess: (_, deletedName) => { - toast.success(t("pages.agent.skills.delete_success")) - setSkillPendingDelete(null) - if ( - selectedSkill?.name === deletedName && - selectedSkill.source === "workspace" - ) { - setSelectedSkill(null) - } - void queryClient.invalidateQueries({ queryKey: ["skills"] }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.skills.delete_error"), - ) - }, - }) - - const handleImportClick = () => { - importInputRef.current?.click() - } - - const handleImportFileChange = (event: ChangeEvent) => { - const file = event.target.files?.[0] - if (!file) return - importMutation.mutate(file) - event.target.value = "" - } - - return ( -
- - - - - } - /> - -
-
- {isLoading ? ( -
- {t("labels.loading")} -
- ) : error ? ( -
- {t("pages.agent.load_error")} -
- ) : ( -
-

- {t("pages.agent.skills.description")} -

- - {data?.skills.length ? ( -
- {data.skills.map((skill) => ( - - -
-
- - {skill.name} - - - {skill.description || - t("pages.agent.skills.no_description")} - -
-
- - {skill.source === "workspace" ? ( - - ) : null} -
-
-
- -
- {t("pages.agent.skills.path")} -
-
- {skill.path} -
-
-
- ))} -
- ) : ( - - - {t("pages.agent.skills.empty")} - - - )} -
- )} -
-
- - { - if (!open) setSelectedSkill(null) - }} - > - - - - {selectedSkill?.name || t("pages.agent.skills.viewer_title")} - - - {selectedSkill?.description || - t("pages.agent.skills.viewer_description")} - - - -
- {isSkillDetailLoading ? ( -
- {t("pages.agent.skills.loading_detail")} -
- ) : skillDetailError ? ( -
- {t("pages.agent.skills.load_detail_error")} -
- ) : selectedSkillDetail ? ( -
-
- - {selectedSkillDetail.content} - -
-
- ) : null} -
-
-
- - { - if (!open) setSkillPendingDelete(null) - }} - > - - - - {t("pages.agent.skills.delete_title")} - - - {t("pages.agent.skills.delete_description", { - name: skillPendingDelete?.name, - })} - - - - - {t("common.cancel")} - - { - if (skillPendingDelete) - deleteMutation.mutate(skillPendingDelete.name) - }} - > - {deleteMutation.isPending ? ( - - ) : ( - - )} - {t("pages.agent.skills.delete_confirm")} - - - - -
- ) -} diff --git a/web/frontend/src/components/tools/tools-page.tsx b/web/frontend/src/components/tools/tools-page.tsx deleted file mode 100644 index 6a521a565..000000000 --- a/web/frontend/src/components/tools/tools-page.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { IconLoader2 } from "@tabler/icons-react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { useTranslation } from "react-i18next" -import { toast } from "sonner" - -import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools" -import { PageHeader } from "@/components/page-header" -import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { cn } from "@/lib/utils" -import { refreshGatewayState } from "@/store/gateway" - -export function ToolsPage() { - const { t } = useTranslation() - const queryClient = useQueryClient() - const { data, isLoading, error } = useQuery({ - queryKey: ["tools"], - queryFn: getTools, - }) - - const toggleMutation = useMutation({ - mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => - setToolEnabled(name, enabled), - onSuccess: (_, variables) => { - toast.success( - variables.enabled - ? t("pages.agent.tools.enable_success") - : t("pages.agent.tools.disable_success"), - ) - void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.tools.toggle_error"), - ) - }, - }) - - const groupedTools = (() => { - if (!data) return [] as Array<[string, ToolSupportItem[]]> - const buckets = new Map() - for (const item of data.tools) { - const list = buckets.get(item.category) ?? [] - list.push(item) - buckets.set(item.category, list) - } - return Array.from(buckets.entries()) - })() - - return ( -
- - -
-
- {isLoading ? ( -
- {t("labels.loading")} -
- ) : error ? ( -
- {t("pages.agent.load_error")} -
- ) : ( -
-

- {t("pages.agent.tools.description")} -

- - {data?.tools.length ? ( - groupedTools.map(([category, items]) => ( -
-
- {t(`pages.agent.tools.categories.${category}`)} -
-
- {items.map((tool) => { - const reasonText = tool.reason_code - ? t(`pages.agent.tools.reasons.${tool.reason_code}`) - : "" - const isPending = - toggleMutation.isPending && - toggleMutation.variables?.name === tool.name - const nextEnabled = tool.status !== "enabled" - - return ( - - -
-
- - {tool.name} - - - {tool.description} - -
-
- - -
-
-
- -
- {t("pages.agent.tools.config_key", { - key: tool.config_key, - })} -
- {reasonText ? ( -
- {reasonText} -
- ) : null} -
-
- ) - })} -
-
- )) - ) : ( - - - {t("pages.agent.tools.empty")} - - - )} -
- )} -
-
-
- ) -} - -function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) { - const { t } = useTranslation() - - return ( - - {t(`pages.agent.tools.status.${status}`)} - - ) -} diff --git a/web/frontend/src/components/ui/dialog.tsx b/web/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..da1eb3a12 --- /dev/null +++ b/web/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,163 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { IconX } from "@tabler/icons-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index d79e90ef7..b99ff9594 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -5,6 +5,7 @@ "models": "Models", "credentials": "Credentials", "agent_group": "Agent", + "hub": "Hub", "skills": "Skills", "tools": "Tools", "services": "Services", @@ -398,11 +399,18 @@ "agent": { "load_error": "Failed to load agent support information.", "skills": { - "description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.", "empty": "No skills are currently available.", + "install_success": "Installed {{name}}.", + "install_error": "Failed to install skill.", + "search_placeholder": "Search by name, description, or registry", + "source_label": "Type", + "sort_label": "Sort", "import": "Import Skill", "import_success": "Skill imported.", "import_error": "Failed to import skill.", + "import_invalid_type": "Only Markdown or ZIP skill files are supported.", + "import_invalid_size": "Skill file must be 1 MB or smaller.", + "import_constraints": "Import a Markdown or ZIP skill file up to 1 MB", "view": "View", "delete": "Delete", "delete_title": "Delete Skill?", @@ -412,20 +420,78 @@ "delete_error": "Failed to delete skill.", "viewer_title": "Skill Content", "viewer_description": "Read the current effective SKILL.md content here.", - "loading_detail": "Loading skill content...", "load_detail_error": "Failed to load skill content.", - "path": "Skill Path", - "no_description": "No description provided." + "no_description": "No description provided.", + "no_results": "No skills matched the current filters.", + "dropzone_title": "Import Into Workspace", + "dropzone_description": "Drag a skill file here or pick one from disk.", + "dropzone_label": "Drop a skill file here", + "dropzone_active": "Release to import this skill", + "dropzone_release": "The skill will be normalized and saved into the workspace skills directory.", + "marketplace_title": "Discover Skills", + "marketplace_description": "Search the skill registries and install useful skills into this workspace", + "marketplace_search_placeholder": "Search for capabilities like github, docker, database...", + "marketplace_search_action": "Search", + "marketplace_search_status": "Search Status", + "marketplace_install_status": "Install Status", + "marketplace_notice_title": "Security Notice", + "marketplace_notice_body": "Registry skills are third-party content. Review the author, page URL, instructions, and any required code or credentials before installing.", + "marketplace_status_disabled": "Disabled. Enable the corresponding tool on the Tools page first.", + "marketplace_status_enable_hint": "Enable the related tool on the Tools page first.", + "marketplace_search_error": "Failed to search registries.", + "marketplace_loading_results": "Searching skills...", + "marketplace_loading_more": "Loading more skills...", + "marketplace_results_title": "{{count}} results for “{{query}}”", + "marketplace_results_hint": "Registry results install into the current workspace.", + "marketplace_install_action": "Install", + "marketplace_installed": "Installed", + "marketplace_view_installed": "View Local", + "marketplace_installed_hint": "Already available in this workspace as “{{name}}”.", + "marketplace_empty_results": "No installable skills matched “{{query}}”.", + "marketplace_idle": "Search for a capability to discover installable skills from configured registries.", + "marketplace_unavailable": "Registry search is currently unavailable. Check the Skills tools configuration.", + "sort": { + "name_asc": "Name (A-Z)", + "name_desc": "Name (Z-A)", + "source": "Type" + }, + "origin": { + "all": "All Types", + "builtin": "Builtin", + "third_party": "Third-Party", + "manual": "Manual" + }, + "summary": { + "total": "Total Skills" + }, + "detail_tabs": { + "preview": "Preview", + "raw": "Raw", + "meta": "Metadata" + }, + "metadata": { + "name": "Name", + "description": "Description", + "registry": "Registry", + "url": "URL", + "version": "Installed Version", + "lines": "Line Count", + "characters": "Character Count" + } }, "tools": { - "description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.", + "search_placeholder": "Search tools...", + "no_results": "No tools match your criteria.", + "filter": { + "all": "All Status", + "enabled": "Enabled only", + "disabled": "Disabled only", + "blocked": "Blocked only" + }, "empty": "No tools are available.", - "enable": "Enable", - "disable": "Disable", "enable_success": "Tool enabled.", "disable_success": "Tool disabled.", "toggle_error": "Failed to update tool state.", - "config_key": "Controlled by tools.{{key}}", "status": { "enabled": "Enabled", "disabled": "Disabled", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e5aa71a44..9fa45e981 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -5,6 +5,7 @@ "models": "模型", "credentials": "凭据", "agent_group": "智能体", + "hub": "Hub", "skills": "技能", "tools": "工具", "services": "服务", @@ -398,11 +399,18 @@ "agent": { "load_error": "加载 Agent 支持信息失败。", "skills": { - "description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。", "empty": "当前没有可用技能。", + "install_success": "已安装 {{name}}。", + "install_error": "安装技能失败。", + "search_placeholder": "按名称、描述或技能源搜索", + "source_label": "类型", + "sort_label": "排序", "import": "导入技能", "import_success": "技能导入成功。", "import_error": "导入技能失败。", + "import_invalid_type": "仅支持导入 Markdown 或 ZIP 技能文件。", + "import_invalid_size": "技能文件大小不能超过 1 MB。", + "import_constraints": "支持导入最大 1 MB 的 Markdown 或 ZIP 文件", "view": "查看", "delete": "删除", "delete_title": "删除技能?", @@ -412,20 +420,78 @@ "delete_error": "删除技能失败。", "viewer_title": "技能内容", "viewer_description": "这里展示当前生效的 SKILL.md 内容。", - "loading_detail": "正在加载技能内容...", "load_detail_error": "加载技能内容失败。", - "path": "技能路径", - "no_description": "未提供描述。" + "no_description": "未提供描述。", + "no_results": "没有技能匹配当前筛选条件。", + "dropzone_title": "导入到工作区", + "dropzone_description": "将技能文件拖到这里,或从本地选择一个文件。", + "dropzone_label": "将技能文件拖到这里", + "dropzone_active": "松开即可导入该技能", + "dropzone_release": "导入后会自动规范化内容,并保存到工作区技能目录。", + "marketplace_title": "安装技能", + "marketplace_description": "搜索第三方技能源,并将技能安装到当前工作区", + "marketplace_search_placeholder": "搜索 github、docker、database 等技能", + "marketplace_search_action": "搜索", + "marketplace_search_status": "搜索状态", + "marketplace_install_status": "安装状态", + "marketplace_notice_title": "安全提示", + "marketplace_notice_body": "搜索结果中的 skills 属于第三方内容。安装前请先确认作者、页面 URL、说明文档,以及它要求执行的代码或使用的凭据是否可信。", + "marketplace_status_disabled": "当前未启用,请先在工具页启用对应工具。", + "marketplace_status_enable_hint": "请先在工具页启用相关工具。", + "marketplace_search_error": "搜索技能源失败。", + "marketplace_loading_results": "正在搜索技能...", + "marketplace_loading_more": "正在加载更多技能...", + "marketplace_results_title": "“{{query}}” 共找到 {{count}} 个结果", + "marketplace_results_hint": "搜索结果会安装到当前工作区。", + "marketplace_install_action": "安装", + "marketplace_installed": "已安装", + "marketplace_view_installed": "查看本地技能", + "marketplace_installed_hint": "该技能已在当前工作区中可用,名称为「{{name}}」。", + "marketplace_empty_results": "没有找到与“{{query}}”匹配的可安装技能。", + "marketplace_idle": "输入一个关键词,搜索可安装的第三方技能。", + "marketplace_unavailable": "当前无法使用技能搜索,请检查 Skills 相关工具配置。", + "sort": { + "name_asc": "名称(A-Z)", + "name_desc": "名称(Z-A)", + "source": "按类型" + }, + "origin": { + "all": "全部类型", + "builtin": "内置", + "third_party": "第三方", + "manual": "手动导入" + }, + "summary": { + "total": "技能总数" + }, + "detail_tabs": { + "preview": "预览", + "raw": "原始内容", + "meta": "元数据" + }, + "metadata": { + "name": "名称", + "description": "描述", + "registry": "来源平台", + "url": "链接地址", + "version": "已安装版本", + "lines": "行数", + "characters": "字符数" + } }, "tools": { - "description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。", + "search_placeholder": "搜索工具...", + "no_results": "没有找到符合条件的工具", + "filter": { + "all": "所有状态", + "enabled": "已启用", + "disabled": "已禁用", + "blocked": "被阻塞" + }, "empty": "当前没有可用工具。", - "enable": "启用", - "disable": "禁用", "enable_success": "工具已启用。", "disable_success": "工具已禁用。", "toggle_error": "更新工具状态失败。", - "config_key": "由 tools.{{key}} 控制", "status": { "enabled": "已启用", "disabled": "已禁用", diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 536ee560b..a32a6150d 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as ConfigRawRouteImport } from './routes/config.raw' import { Route as ChannelsNameRouteImport } from './routes/channels/$name' import { Route as AgentToolsRouteImport } from './routes/agent/tools' import { Route as AgentSkillsRouteImport } from './routes/agent/skills' +import { Route as AgentHubRouteImport } from './routes/agent/hub' const ModelsRoute = ModelsRouteImport.update({ id: '/models', @@ -82,6 +83,11 @@ const AgentSkillsRoute = AgentSkillsRouteImport.update({ path: '/skills', getParentRoute: () => AgentRoute, } as any) +const AgentHubRoute = AgentHubRouteImport.update({ + id: '/hub', + path: '/hub', + getParentRoute: () => AgentRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -92,6 +98,7 @@ export interface FileRoutesByFullPath { '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute + '/agent/hub': typeof AgentHubRoute '/agent/skills': typeof AgentSkillsRoute '/agent/tools': typeof AgentToolsRoute '/channels/$name': typeof ChannelsNameRoute @@ -106,6 +113,7 @@ export interface FileRoutesByTo { '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute + '/agent/hub': typeof AgentHubRoute '/agent/skills': typeof AgentSkillsRoute '/agent/tools': typeof AgentToolsRoute '/channels/$name': typeof ChannelsNameRoute @@ -121,6 +129,7 @@ export interface FileRoutesById { '/launcher-login': typeof LauncherLoginRoute '/logs': typeof LogsRoute '/models': typeof ModelsRoute + '/agent/hub': typeof AgentHubRoute '/agent/skills': typeof AgentSkillsRoute '/agent/tools': typeof AgentToolsRoute '/channels/$name': typeof ChannelsNameRoute @@ -137,6 +146,7 @@ export interface FileRouteTypes { | '/launcher-login' | '/logs' | '/models' + | '/agent/hub' | '/agent/skills' | '/agent/tools' | '/channels/$name' @@ -151,6 +161,7 @@ export interface FileRouteTypes { | '/launcher-login' | '/logs' | '/models' + | '/agent/hub' | '/agent/skills' | '/agent/tools' | '/channels/$name' @@ -165,6 +176,7 @@ export interface FileRouteTypes { | '/launcher-login' | '/logs' | '/models' + | '/agent/hub' | '/agent/skills' | '/agent/tools' | '/channels/$name' @@ -268,6 +280,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AgentSkillsRouteImport parentRoute: typeof AgentRoute } + '/agent/hub': { + id: '/agent/hub' + path: '/hub' + fullPath: '/agent/hub' + preLoaderRoute: typeof AgentHubRouteImport + parentRoute: typeof AgentRoute + } } } @@ -284,11 +303,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren( ) interface AgentRouteChildren { + AgentHubRoute: typeof AgentHubRoute AgentSkillsRoute: typeof AgentSkillsRoute AgentToolsRoute: typeof AgentToolsRoute } const AgentRouteChildren: AgentRouteChildren = { + AgentHubRoute: AgentHubRoute, AgentSkillsRoute: AgentSkillsRoute, AgentToolsRoute: AgentToolsRoute, } diff --git a/web/frontend/src/routes/agent.tsx b/web/frontend/src/routes/agent.tsx index 78104de5b..149d095cd 100644 --- a/web/frontend/src/routes/agent.tsx +++ b/web/frontend/src/routes/agent.tsx @@ -15,7 +15,7 @@ function AgentLayout() { }) if (pathname === "/agent") { - return + return } return diff --git a/web/frontend/src/routes/agent/hub.tsx b/web/frontend/src/routes/agent/hub.tsx new file mode 100644 index 000000000..032d19c05 --- /dev/null +++ b/web/frontend/src/routes/agent/hub.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { HubPage } from "@/components/agent/hub/hub-page" + +export const Route = createFileRoute("/agent/hub")({ + component: AgentHubRoute, +}) + +function AgentHubRoute() { + return +} diff --git a/web/frontend/src/routes/agent/skills.tsx b/web/frontend/src/routes/agent/skills.tsx index bbe396bdb..58890594a 100644 --- a/web/frontend/src/routes/agent/skills.tsx +++ b/web/frontend/src/routes/agent/skills.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router" -import { SkillsPage } from "@/components/skills/skills-page" +import { SkillsPage } from "@/components/agent/skills/skills-page" export const Route = createFileRoute("/agent/skills")({ component: AgentSkillsRoute, diff --git a/web/frontend/src/routes/agent/tools.tsx b/web/frontend/src/routes/agent/tools.tsx index ac8738a8f..f33553eba 100644 --- a/web/frontend/src/routes/agent/tools.tsx +++ b/web/frontend/src/routes/agent/tools.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router" -import { ToolsPage } from "@/components/tools/tools-page" +import { ToolsPage } from "@/components/agent/tools/tools-page" export const Route = createFileRoute("/agent/tools")({ component: AgentToolsRoute, From 31afad6e87b48d7d863ffd39d8b42ed6da762d0c Mon Sep 17 00:00:00 2001 From: reusu Date: Wed, 1 Apr 2026 21:32:10 +0800 Subject: [PATCH 66/72] feat: add load_image tool for local file vision (#2116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add load_image tool for local file vision * fix: address load_image PR review feedback - Exclude load_image from sub-agent tools via Unregister after Clone, since RunToolLoop does not call resolveMediaRefs - Add ToolRegistry.Unregister() method - Fix scope collision: use channel:chatID instead of filename - Add channel/chatID context resolution matching send_file pattern - Add comment explaining iteration > 1 guard on resolveMediaRefs - Remove emoji from ForUser for consistency with send_file - Add load_image_test.go * feat: enable load_image for subagents via MediaResolver in RunToolLoop Instead of removing load_image from sub-agent tools (28f69e71), inject a MediaResolver into the legacy RunToolLoop fallback path so media:// refs are resolved to base64 before each LLM call — matching the main agent loop behavior. - Add MediaResolver field to ToolLoopConfig and call it on iteration > 1 - Add SubagentManager.SetMediaResolver() and wire it through runTask - Remove ToolRegistry.Unregister() (no longer needed) - Restore load_image in sub-agent tool set (revert Clone+Unregister) - Add TestSubagentManager_SetMediaResolver_StoresResolver * refactor(load_image): remove prompt parameter from tool schema * test(tools): add success-path test for LoadImageTool Add TestLoadImage_SuccessPath that creates a real PNG file with valid magic bytes, calls Execute with WithToolContext, and verifies: - result.IsError == false - ToolResult.Media contains a media:// ref - ToolResult.ForLLM contains the [image: marker - media ref is resolvable in the store Add explanatory comment in loop.go for why Media and ArtifactTags coexist on non-ResponseHandled tool results (e.g. load_image). * fix: preallocate slice in tests and add ResponseHandled guard in toolloop Fix prealloc linter failure in load_image_test.go. Prevent double-resolving media by checking ResponseHandled in toolloop.go. * Register TTS tool if provider is available --------- Co-authored-by: Reusu Co-authored-by: 美電球 --- pkg/agent/loop.go | 37 ++++++++ pkg/tools/load_image.go | 163 ++++++++++++++++++++++++++++++++ pkg/tools/load_image_test.go | 174 +++++++++++++++++++++++++++++++++++ pkg/tools/subagent.go | 19 ++++ pkg/tools/toolloop.go | 36 +++++++- 5 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 pkg/tools/load_image.go create mode 100644 pkg/tools/load_image_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b376ed0af..19c1f0369 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -281,6 +281,17 @@ func registerSharedTools( agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) } + if cfg.Tools.IsToolEnabled("load_image") { + loadImageTool := tools.NewLoadImageTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(loadImageTool) + } + // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") @@ -323,6 +334,14 @@ func registerSharedTools( subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + // Inject a media resolver so the legacy RunToolLoop fallback path can + // resolve media:// refs in the same way the main AgentLoop does. + // This keeps subagent vision support working even when the optimized + // sub-turn spawner path is unavailable. + subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) + }) + // Set the spawner that links into AgentLoop's turnState subagentManager.SetSpawner(func( ctx context.Context, @@ -1861,6 +1880,14 @@ turnLoop: providerToolDefs = filtered } + // Resolve media:// refs produced by tool results (e.g. load_image). + // Skipped on iteration 1 because inbound user media is already resolved + // before entering the loop; only subsequent iterations can contain new + // tool-generated media refs that need base64 encoding. + if iteration > 1 { + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + } + callMessages := messages if gracefulTerminal { callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) @@ -2551,6 +2578,13 @@ turnLoop: } if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + // For tools like load_image that produce media refs without sending them + // to the user channel (ResponseHandled == false), both Media and ArtifactTags + // coexist on the result: + // - Media: carries media:// refs that resolveMediaRefs will base64-encode + // into image_url parts in the next LLM iteration (enabling vision). + // - ArtifactTags: exposes the local file path as a structured [file:…] tag + // in the tool result text, so the LLM knows an artifact was produced. toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) } @@ -2570,6 +2604,9 @@ turnLoop: Content: contentForLLM, ToolCallID: toolCallID, } + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) + } al.emitEvent( EventKindToolExecEnd, ts.eventMeta("runTurn", "turn.tool.end"), diff --git a/pkg/tools/load_image.go b/pkg/tools/load_image.go new file mode 100644 index 000000000..41ea6d054 --- /dev/null +++ b/pkg/tools/load_image.go @@ -0,0 +1,163 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +// LoadImageTool loads a local image file into the MediaStore and returns a +// media:// reference. The agent loop's resolveMediaRefs will then base64-encode +// it and attach it as an image_url part in the next LLM request, enabling +// vision on local files — the same pipeline used when a user sends an image +// through a chat channel. +// +// This is intentionally different from SendFileTool: +// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn +// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn +type LoadImageTool struct { + workspace string + restrict bool + maxFileSize int + mediaStore media.MediaStore + allowPaths []*regexp.Regexp + + defaultChannel string + defaultChatID string +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + if maxFileSize <= 0 { + maxFileSize = config.DefaultMaxMediaSize + } + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &LoadImageTool{ + workspace: workspace, + restrict: restrict, + maxFileSize: maxFileSize, + mediaStore: store, + allowPaths: patterns, + } +} + +func (t *LoadImageTool) Name() string { return "load_image" } + +func (t *LoadImageTool) Description() string { + return "Load a local image file so you can analyze its contents with vision. " + + "Supported formats: JPEG, PNG, GIF, WebP, BMP. " + + "After calling this tool, describe or analyze the image in your next response." +} + +func (t *LoadImageTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the local image file. Relative paths are resolved from workspace.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *LoadImageTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *LoadImageTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, _ := args["path"].(string) + if strings.TrimSpace(path) == "" { + return ErrorResult("path is required") + } + + // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values. + channel := ToolChannel(ctx) + if channel == "" { + channel = t.defaultChannel + } + chatID := ToolChatID(ctx) + if chatID == "" { + chatID = t.defaultChatID + } + if channel == "" || chatID == "" { + return ErrorResult("no target channel/chat available") + } + + if t.mediaStore == nil { + return ErrorResult("media store not configured") + } + + resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } + + info, err := os.Stat(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("file not found: %v", err)) + } + if info.IsDir() { + return ErrorResult("path is a directory, expected an image file") + } + if info.Size() > int64(t.maxFileSize) { + return ErrorResult(fmt.Sprintf( + "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize, + )) + } + + // Detect MIME type — reuse the helper already in send_file.go + mediaType := detectMediaType(resolved) + if !strings.HasPrefix(mediaType, "image/") { + return ErrorResult(fmt.Sprintf( + "file does not appear to be an image (detected type: %s)", mediaType, + )) + } + + filename := filepath.Base(resolved) + scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID) + + ref, err := t.mediaStore.Store(resolved, media.MediaMeta{ + Filename: filename, + ContentType: mediaType, + Source: "tool:load_image", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, scope) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err)) + } + + // Build the tool result text. The media:// ref will be picked up by + // resolveMediaRefs in loop_media.go and converted to a base64 data URL + // before the next LLM call, exactly like channel-received images. + msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref) + + return &ToolResult{ + ForLLM: msg, + ForUser: fmt.Sprintf("Loaded image: %s", filename), + // Media refs inside ForLLM are resolved by resolveMediaRefs in the + // agent loop before the next LLM call. Do NOT use MediaResult here — + // that would send the file to the user channel instead. + Media: []string{ref}, + } +} diff --git a/pkg/tools/load_image_test.go b/pkg/tools/load_image_test.go new file mode 100644 index 000000000..91118f93e --- /dev/null +++ b/pkg/tools/load_image_test.go @@ -0,0 +1,174 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestLoadImage_PathRequired(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error for missing path") + } +} + +func TestLoadImage_NilMediaStore(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": "test.png"}) + if !result.IsError || result.ForLLM != "media store not configured" { + t.Fatalf("expected media store error, got: %s", result.ForLLM) + } +} + +func TestLoadImage_NoChannelContext(t *testing.T) { + store := media.NewFileMediaStore() + tool := NewLoadImageTool("/tmp", false, 0, store) + // No WithToolContext — should fail + result := tool.Execute(context.Background(), map[string]any{"path": "test.png"}) + if !result.IsError || result.ForLLM != "no target channel/chat available" { + t.Fatalf("expected channel error, got: %s", result.ForLLM) + } +} + +func TestLoadImage_NonImageFile(t *testing.T) { + dir := t.TempDir() + txtFile := filepath.Join(dir, "readme.txt") + os.WriteFile(txtFile, []byte("hello"), 0o644) + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 0, store) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": txtFile}) + if !result.IsError { + t.Fatal("expected error for non-image file") + } +} + +func TestLoadImage_DefaultMaxSize(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + if tool.maxFileSize != config.DefaultMaxMediaSize { + t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) + } +} + +func TestLoadImage_FileTooLarge(t *testing.T) { + dir := t.TempDir() + bigFile := filepath.Join(dir, "big.png") + // Create a file with PNG header but exceeding max size + data := make([]byte, 1024) + copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes + os.WriteFile(bigFile, data, 0o644) + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512 + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": bigFile}) + if !result.IsError { + t.Fatal("expected error for oversized file") + } +} + +func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { + manager := NewSubagentManager(nil, "gpt-test", "/tmp") + + called := false + manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + called = true + return msgs + }) + + manager.mu.RLock() + got := manager.mediaResolver + manager.mu.RUnlock() + + if got == nil { + t.Fatal("expected mediaResolver to be set") + } + + if called { + t.Fatal("resolver should not be called during SetMediaResolver") + } +} + +func TestLoadImage_SuccessPath(t *testing.T) { + dir := t.TempDir() + + // Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND). + // The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n + pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + // IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC + ihdr := []byte{ + 0x00, 0x00, 0x00, 0x0D, // chunk length = 13 + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width = 1 + 0x00, 0x00, 0x00, 0x01, // height = 1 + 0x08, // bit depth = 8 + 0x02, // color type = RGB + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR) + } + // IEND chunk + iend := []byte{ + 0x00, 0x00, 0x00, 0x00, // chunk length = 0 + 0x49, 0x45, 0x4E, 0x44, // "IEND" + 0xAE, 0x42, 0x60, 0x82, // CRC + } + + pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend)) + pngData = append(pngData, pngSignature...) + pngData = append(pngData, ihdr...) + pngData = append(pngData, iend...) + + imgPath := filepath.Join(dir, "test_image.png") + if err := os.WriteFile(imgPath, pngData, 0o644); err != nil { + t.Fatalf("failed to create test PNG: %v", err) + } + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 0, store) + ctx := WithToolContext(context.Background(), "test", "chat1") + + result := tool.Execute(ctx, map[string]any{"path": imgPath}) + + // 1. Must not be an error + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + + // 2. Media must contain exactly one media:// ref + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } + if !strings.HasPrefix(result.Media[0], "media://") { + t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0]) + } + + // 3. ForLLM must contain the [image: marker + if !strings.Contains(result.ForLLM, "[image:") { + t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM) + } + + // 4. ForLLM should also contain the media:// ref + if !strings.Contains(result.ForLLM, result.Media[0]) { + t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM) + } + + // 5. Verify the ref is resolvable in the store + resolved, err := store.Resolve(result.Media[0]) + if err != nil { + t.Fatalf("media ref not resolvable: %v", err) + } + if resolved != imgPath { + t.Errorf("expected resolved path %q, got %q", imgPath, resolved) + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 9a1a8b802..ada89efb7 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -67,6 +67,12 @@ type SubagentManager struct { hasTemperature bool nextID int spawner SpawnSubTurnFunc + + // mediaResolver resolves media:// refs in tool-loop messages before + // each LLM call in the legacy RunToolLoop fallback path. + // This lets subagents reuse the same media handling behavior as the + // main agent loop without importing pkg/agent and creating a cycle. + mediaResolver func([]providers.Message) []providers.Message } func NewSubagentManager( @@ -90,6 +96,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) { sm.spawner = spawner } +// SetMediaResolver injects a message preprocessor that resolves media:// refs +// into LLM-ready content before each tool-loop iteration. +// This is only used by the legacy RunToolLoop fallback path. +func (sm *SubagentManager) SetMediaResolver( + resolver func([]providers.Message) []providers.Message, +) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.mediaResolver = resolver +} + // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() @@ -177,6 +194,7 @@ func (sm *SubagentManager) runTask( temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature + mediaResolver := sm.mediaResolver sm.mu.RUnlock() var result *ToolResult @@ -223,6 +241,7 @@ After completing the task, provide a clear summary of what was done.` Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, + MediaResolver: mediaResolver, }, messages, task.OriginChannel, task.OriginChatID) if err == nil { diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 387813e94..ac568f598 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -24,6 +24,11 @@ type ToolLoopConfig struct { Tools *ToolRegistry MaxIterations int LLMOptions map[string]any + + // MediaResolver resolves media:// refs in messages before each LLM call. + // This is optional and is mainly used by subagent legacy fallback execution + // so subagents can reuse the same multimodal media handling as the main loop. + MediaResolver func(messages []providers.Message) []providers.Message } // ToolLoopResult contains the result of running the tool loop. @@ -63,8 +68,27 @@ func RunToolLoop( if llmOpts == nil { llmOpts = map[string]any{} } - // 3. Call LLM - response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) + + // 3. Resolve media:// refs and Call LLM. + // Tools like load_image produce media:// refs in their result messages. + // Without this step, the LLM would receive raw "media://uuid" strings + // instead of base64-encoded image data URLs. + // + // We build a separate callMessages slice so that: + // (a) the resolver output is used for the LLM call only, + // (b) the original `messages` slice keeps the unresolved refs for + // subsequent iterations — the resolver is idempotent but working + // on the original avoids double-encoding issues. + // + // On iteration 1 the initial user messages typically have no media:// + // refs (they come from plain text), so this is effectively a no-op; + // it becomes relevant from iteration 2 onward when tool results may + // contain media refs. + callMessages := messages + if config.MediaResolver != nil && iteration > 1 { + callMessages = config.MediaResolver(messages) + } + response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", map[string]any{ @@ -161,11 +185,15 @@ func RunToolLoop( for _, r := range results { contentForLLM := r.result.ContentForLLM() - messages = append(messages, providers.Message{ + toolMsg := providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: r.tc.ID, - }) + } + if len(r.result.Media) > 0 && !r.result.ResponseHandled { + toolMsg.Media = append(toolMsg.Media, r.result.Media...) + } + messages = append(messages, toolMsg) } } From e2a9bb97c72f0852a19c847c142ace1120944bbc Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 23:26:49 +0800 Subject: [PATCH 67/72] unify all panic event to panic log file (#2250) --- pkg/agent/loop.go | 1 + pkg/agent/subturn.go | 1 + pkg/channels/discord/voice.go | 1 + pkg/logger/panic.go | 40 ++++++++++++++++++++-------- pkg/tools/registry.go | 1 + web/backend/middleware/middleware.go | 1 + 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 19c1f0369..15535e138 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -991,6 +991,7 @@ func (al *AgentLoop) ReloadProviderAndConfig( go func() { defer func() { if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) panicErr = fmt.Errorf("panic during registry creation: %v", r) logger.ErrorCF("agent", "Panic during registry creation", map[string]any{"panic": r}) diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index f5ba412ab..82a2a2010 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -427,6 +427,7 @@ func spawnSubTurn( // 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics defer func() { if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) err = fmt.Errorf("subturn panicked: %v", r) result = nil logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{ diff --git a/pkg/channels/discord/voice.go b/pkg/channels/discord/voice.go index 5b686b141..554b8ae71 100644 --- a/pkg/channels/discord/voice.go +++ b/pkg/channels/discord/voice.go @@ -120,6 +120,7 @@ func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, defer func() { if rec := recover(); rec != nil { retErr = fmt.Errorf("voice connection closed during playback") + logger.RecoverPanicNoExit(rec) } }() diff --git a/pkg/logger/panic.go b/pkg/logger/panic.go index e53e4351a..0a9125dda 100644 --- a/pkg/logger/panic.go +++ b/pkg/logger/panic.go @@ -2,12 +2,15 @@ package logger import ( "fmt" + "io" "os" "path/filepath" "runtime/debug" "time" ) +var panicWriter io.WriteCloser + func InitPanic(filePath string) (func(), error) { if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return nil, fmt.Errorf("failed to create log directory: %w", err) @@ -16,21 +19,36 @@ func InitPanic(filePath string) (func(), error) { if writer == nil { return nil, fmt.Errorf("failed to create log file: %s", filePath) } + if panicWriter != nil { + _ = panicWriter.Close() + } + panicWriter = writer return func() { - defer writer.Close() + defer func() { + writer.Close() + panicWriter = nil + }() if err := recover(); err != nil { - now := time.Now().Format("2006-01-02 15:04:05") - stack := debug.Stack() - logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( - "%v", - err, - ) + "\n" + string( - stack, - ) - - writer.Write([]byte(logMsg)) + RecoverPanicNoExit(err) os.Exit(1) } }, nil } + +func RecoverPanicNoExit(err any) { + if panicWriter == nil { + Errorf("panicWriter is nil, should not happen") + return + } + now := time.Now().Format("2006-01-02 15:04:05") + stack := debug.Stack() + logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( + "%v", + err, + ) + "\n" + string( + stack, + ) + + panicWriter.Write([]byte(logMsg)) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 56af8d695..e51dff71a 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -228,6 +228,7 @@ func (r *ToolRegistry) ExecuteWithContext( func() { defer func() { if re := recover(); re != nil { + logger.RecoverPanicNoExit(re) errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re) logger.ErrorCF("tool", "Tool execution panic recovered", map[string]any{ diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go index a0b7eb998..f9eb3149d 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -71,6 +71,7 @@ func Recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { + logger.RecoverPanicNoExit(err) logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack())) http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) } From 49e61fa07f07abdd57b96fbb0c40b64702d21868 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Wed, 1 Apr 2026 23:41:32 +0800 Subject: [PATCH 68/72] feat(updater): robust self-update selection & extraction (nightly default) (#2201) * feat(updater): add web self-update endpoint and updater package * feat(selfupgrade): when url empty, using GetTestReleaseAPIURL for test . * feat(selfupgrade): only GetTestReleaseAPIURL . * feat(upgrade): cli $0 update work well! * fix(ci): fix ci err * fix(test): fix ci test * fix(ci): fix ci lint fmt err * test(updater): add test for updater * fix(ci): fix ci lint var copy err * fix(ci): retry ci * updater: require checksum verification, prefer API digest, verify SHA256, fix zip extraction, update tests * fix(lint): lint fixed * fix(lint): lint fixed2 * updater: stream download and verify sha256; add http client timeout and progress Avoid double-download by streaming asset into temp file while computing SHA256 and verifying against checksum; replace http.Get with shared httpClient (2m timeout) to prevent hangs; add simple stderr progress display; remove unused helpers. --- cmd/picoclaw/main.go | 2 + cmd/picoclaw/main_test.go | 1 + go.mod | 2 + go.sum | 11 + pkg/updater/updater.go | 707 ++++++++++++++++++++++++++++++++++++ pkg/updater/updater_test.go | 97 +++++ web/backend/api/router.go | 3 + web/backend/api/update.go | 52 +++ 8 files changed, 875 insertions(+) create mode 100644 pkg/updater/updater.go create mode 100644 pkg/updater/updater_test.go create mode 100644 web/backend/api/update.go diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index bf9c0389f..48dffbb33 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -24,6 +24,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/updater" ) func NewPicoclawCommand() *cobra.Command { @@ -45,6 +46,7 @@ func NewPicoclawCommand() *cobra.Command { migrate.NewMigrateCommand(), skills.NewSkillsCommand(), model.NewModelCommand(), + updater.NewUpdateCommand("picoclaw"), version.NewVersionCommand(), ) diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index ad18cb330..cb221dece 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -43,6 +43,7 @@ func TestNewPicoclawCommand(t *testing.T) { "onboard", "skills", "status", + "update", "version", } diff --git a/go.mod b/go.mod index 5f311306e..3fa15b427 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 + github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 @@ -48,6 +49,7 @@ require ( ) require ( + aead.dev/minisign v0.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect diff --git a/go.sum b/go.sum index ca5dd0423..c1fef5983 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= @@ -184,6 +186,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= @@ -308,7 +312,10 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= @@ -329,6 +336,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -351,11 +359,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -369,6 +379,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go new file mode 100644 index 000000000..e73c1e859 --- /dev/null +++ b/pkg/updater/updater.go @@ -0,0 +1,707 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// httpClient is a shared HTTP client used for release checks and downloads. +// The Timeout value applies to the entire HTTP request: dialing, TLS +// handshake, redirects, and reading the response body. It is NOT only +// a connection (dial) timeout. To control lower-level timeouts (dial, +// TLS handshake, response header wait), supply a custom Transport with +// an appropriately configured net.Dialer. +var httpClient = &http.Client{Timeout: 2 * time.Minute} + +// DownloadAndExtractRelease downloads a release archive (or uses a direct +// asset URL) and extracts it to a temporary directory. It returns the +// extraction directory on success. If releaseURL is empty, the latest +// release of the current project is used. platform/arch can be used to +// select the correct asset (e.g. "linux", "amd64"). +func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) { + assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch) + if err != nil { + return "", err + } + + // Download asset to temp file. Use the asset URL extension so + // extractArchive can detect the archive format (zip/tar.gz/tar). + tmpPattern := "picoclaw-release-*" + if u, perr := url.Parse(assetURL); perr == nil { + base := filepath.Base(u.Path) + lbase := strings.ToLower(base) + switch { + case strings.HasSuffix(lbase, ".zip"): + tmpPattern += ".zip" + case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"): + tmpPattern += ".tar.gz" + case strings.HasSuffix(lbase, ".tar"): + tmpPattern += ".tar" + default: + tmpPattern += ".archive" + } + } else { + tmpPattern += ".archive" + } + + tmpFile, err := os.CreateTemp("", tmpPattern) + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + defer tmpFile.Close() + + resp, err := httpClient.Get(assetURL) + if err != nil { + os.Remove(tmpPath) + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode) + } + + // Stream download while computing SHA256 to avoid a second download. + // Also show a simple progress line to stderr so users see activity. + h := sha256.New() + pw := &progressWriter{total: resp.ContentLength} + mw := io.MultiWriter(tmpFile, h, pw) + if _, err = io.Copy(mw, resp.Body); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + // ensure final progress line ends with newline + pw.Finish() + + // verify checksum if available + if checksum != "" { + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, checksum) { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum) + } + } + + // Extract + destDir, err := os.MkdirTemp("", "picoclaw-extract-*") + if err != nil { + os.Remove(tmpPath) + return "", err + } + + if err := extractArchive(tmpPath, destDir); err != nil { + os.Remove(tmpPath) + os.RemoveAll(destDir) + return "", err + } + + // cleanup archive file; keep extracted contents + _ = os.Remove(tmpPath) + return destDir, nil +} + +// UpdateSelfFromRelease downloads the release matching the given parameters, +// extracts it and applies the binary named programName to update the +// currently running executable using minio/selfupdate. +// If releaseURL is empty, the latest release is used. If platform or arch +// is empty, runtime values are used. +func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error { + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + } + + dir, err := DownloadAndExtractRelease(releaseURL, platform, arch) + if err != nil { + return err + } + defer os.RemoveAll(dir) + + binPath, err := findBinaryInDir(dir, programName) + if err != nil { + return err + } + + // ensure executable bit on non-windows + if runtime.GOOS != "windows" { + _ = os.Chmod(binPath, 0o755) + } + + f, err := os.Open(binPath) + if err != nil { + return err + } + defer f.Close() + + // Backup current executable so we can roll back if needed. + var opts selfupdate.Options + if exePath, err := os.Executable(); err == nil { + opts.OldSavePath = exePath + ".old" + } + + if err := selfupdate.Apply(f, opts); err != nil { + return fmt.Errorf("apply update: %w", err) + } + + return nil +} + +// UpdateSelf updates the running executable by fetching the latest release +// and applying the binary matching programName. +func UpdateSelf(programName string) error { + // By default, select the latest stable release when no explicit + // release URL is provided. Use --nightly or a custom URL to override. + return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName) +} + +// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner. +// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest +func GetReleaseAPIURL(owner string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner) +} + +// GetProdReleaseAPIURL returns the production release API URL (upstream). +func GetProdReleaseAPIURL() string { + return GetReleaseAPIURL("sipeed") +} + +// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag. +// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly +func GetReleaseTagAPIURL(owner, tag string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag) +} + +// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo. +func GetNightlyReleaseAPIURL() string { + return GetReleaseTagAPIURL("sipeed", "nightly") +} + +// findAssetURL resolves the appropriate asset URL for the given release +// selector. It accepts direct archive URLs as well as GitHub release URLs +// or empty (latest release for the project). +func findAssetInfo(releaseURL, platform, arch string) (string, string, error) { + // returns (assetURL, sha256ChecksumHex, error) + if looksLikeDirectAssetURL(releaseURL) { + return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL) + } + + apiURL := buildReleaseAPIURL(releaseURL) + if apiURL == "" { + // If caller provided an empty releaseURL, default to the + // production latest release API URL (stable release). + apiURL = GetProdReleaseAPIURL() + } + + resp, err := httpClient.Get(apiURL) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode) + } + + var data struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", "", err + } + + // Selection order: platform -> arch -> extension. + platformLower := strings.ToLower(platform) + archLower := strings.ToLower(arch) + + isZip := func(name string) bool { + return strings.HasSuffix(name, ".zip") + } + isTarGz := func(name string) bool { + return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz") + } + isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") } + + // collect indices of assets that contain platform (if provided) + var platformIdx []int + for i, a := range data.Assets { + n := strings.ToLower(a.Name) + if platform == "" || strings.Contains(n, platformLower) { + platformIdx = append(platformIdx, i) + } + } + + pickBest := func(idxs []int) (string, int, bool) { + if len(idxs) == 0 { + return "", -1, false + } + // prefer arch matches within idxs; if arch was specified but + // no arch match exists among idxs, treat as no candidate. + var archIdx []int + if arch != "" { + aliases := archAliases(archLower) + for _, i := range idxs { + n := strings.ToLower(data.Assets[i].Name) + for _, ali := range aliases { + if strings.Contains(n, ali) { + archIdx = append(archIdx, i) + break + } + } + } + if len(archIdx) == 0 { + return "", -1, false + } + } + candidates := archIdx + if len(candidates) == 0 { + candidates = idxs + } + + // extension preference + if platformLower == "windows" { + // prefer .zip only + for _, i := range candidates { + if isZip(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + // if no zip found, fallthrough to first candidate + return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true + } + + // non-windows: prefer tar.gz/tgz, then tar, then zip + for _, i := range candidates { + if isTarGz(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + for _, i := range candidates { + if isTar(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + for _, i := range candidates { + if isZip(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + // fallback to first candidate + return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true + } + + // Try platform matches first + if url, idx, ok := pickBest(platformIdx); ok { + // attempt to find checksum: prefer asset digest from API if present + if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" { + dLower := strings.ToLower(d) + if strings.HasPrefix(dLower, "sha256:") { + hexpart := strings.TrimPrefix(dLower, "sha256:") + return url, hexpart, nil + } + // If digest already looks like a 64-hex, return it + if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok { + return url, dLower, nil + } + } + // Look for checksum assets and verify by computing the asset's sha256. + for j, a := range data.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, "sha256") || + strings.Contains(n, "sha256sum") || + strings.Contains(n, "checksums") || + strings.HasSuffix(n, ".sha256") || + strings.HasSuffix(n, ".sha256sum") { + resp2, err := httpClient.Get(data.Assets[j].BrowserDownloadURL) + if err != nil { + continue + } + bs, err := io.ReadAll(resp2.Body) + resp2.Body.Close() + if err != nil { + continue + } + if h, ok := findHashInChecksumContent(bs, url); ok { + return url, h, nil + } + } + } + // No checksum found for the selected platform asset -> error + return "", "", fmt.Errorf("no checksum found for asset %s", url) + } + + // No platform match — require explicit platform+arch; fail fast. + return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch) +} + +func looksLikeDirectAssetURL(u string) bool { + if u == "" { + return false + } + lower := strings.ToLower(u) + if strings.HasSuffix(lower, ".zip") || + strings.HasSuffix(lower, ".tar.gz") || + strings.HasSuffix(lower, ".tgz") || + strings.HasSuffix(lower, ".tar") { + return true + } + if strings.Contains(lower, "/releases/download/") { + return true + } + return false +} + +func buildReleaseAPIURL(releaseURL string) string { + if releaseURL == "" { + return "" + } + if strings.Contains(releaseURL, "api.github.com") { + return releaseURL + } + u, err := url.Parse(releaseURL) + if err != nil { + return "" + } + if u.Host != "github.com" { + return "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "" + } + owner := parts[0] + repo := parts[1] + // if tag specified + if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" { + tag := parts[4] + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag) + } + // default to latest + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) +} + +// NOTE: helper functions to compute SHA256 from URL/path were removed +// after refactoring to stream the download and verify the checksum +// during the single download to avoid double-transfer. + +// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the +// checksum file content that corresponds to assetURL. It returns the +// found hash (lowercase) and true, or "", false if not found. +func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) { + s := strings.ToLower(string(bs)) + var assetBase string + if u, err := url.Parse(assetURL); err == nil { + assetBase = strings.ToLower(filepath.Base(u.Path)) + } else { + assetBase = strings.ToLower(filepath.Base(assetURL)) + } + re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`) + // prefer a line containing the asset filename + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, assetBase) { + if m := re.FindString(line); m != "" { + return m, true + } + } + } + // fallback: if there's exactly one unique 64-hex value, return it + matches := re.FindAllString(s, -1) + uniq := map[string]struct{}{} + for _, m := range matches { + uniq[m] = struct{}{} + } + if len(uniq) == 1 { + for k := range uniq { + return k, true + } + } + return "", false +} + +// progressWriter implements io.Writer and prints a simple progress +// line to stderr while bytes are written. It is intended to be used +// as one writer in an io.MultiWriter so we can stream-to-disk, compute +// the sha256, and update the progress display in a single pass. +type progressWriter struct { + total int64 + written int64 + last time.Time +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n := len(p) + pw.written += int64(n) + now := time.Now() + if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) { + pw.print() + pw.last = now + } + return n, nil +} + +func (pw *progressWriter) print() { + if pw.total > 0 { + pct := float64(pw.written) * 100.0 / float64(pw.total) + fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct) + } else { + fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written)) + } +} + +func (pw *progressWriter) Finish() { + pw.print() + fmt.Fprintln(os.Stderr, "") +} + +func humanBytes(n int64) string { + f := float64(n) + const ( + KB = 1024.0 + MB = KB * 1024.0 + GB = MB * 1024.0 + ) + switch { + case f >= GB: + return fmt.Sprintf("%.2f GB", f/GB) + case f >= MB: + return fmt.Sprintf("%.2f MB", f/MB) + case f >= KB: + return fmt.Sprintf("%.2f KB", f/KB) + default: + return fmt.Sprintf("%d B", n) + } +} + +// archAliases returns common name variants for an architecture string +// so we can match release asset names like "x86_64" vs Go's "amd64". +// archAliases returns name variants for an architecture string. +// If `arch` is empty or matches the local runtime.GOARCH, prefer the +// compile-time architecture aliases provided by archAliasesForLocal +// (implemented per-architecture via build tags). For other `arch` +// values we use a small synonyms map. +func archAliases(arch string) []string { + a := strings.ToLower(arch) + if syns, ok := archSynonyms[a]; ok { + return syns + } + return []string{a} +} + +var archSynonyms = map[string][]string{ + "amd64": {"amd64", "x86_64", "x64"}, + "x86_64": {"amd64", "x86_64", "x64"}, + "x64": {"amd64", "x86_64", "x64"}, + "386": {"386", "x86"}, + "x86": {"386", "x86"}, + "arm64": {"arm64", "aarch64"}, + "aarch64": {"arm64", "aarch64"}, + "arm": {"arm"}, +} + +func extractArchive(archivePath, destDir string) error { + lower := strings.ToLower(archivePath) + if strings.HasSuffix(lower, ".zip") { + return extractZip(archivePath, destDir) + } + // treat .tar.gz and .tgz as gzip+tar + if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") { + return extractTarGz(archivePath, destDir) + } + if strings.HasSuffix(lower, ".tar") { + return extractTar(archivePath, destDir) + } + // fallback: try tar.gz + return extractTarGz(archivePath, destDir) +} + +func extractZip(archivePath, destDir string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer r.Close() + destClean := filepath.Clean(destDir) + for _, f := range r.File { + target := filepath.Clean(filepath.Join(destClean, f.Name)) + if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean { + return fmt.Errorf("path traversal detected: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode()) + if err != nil { + rc.Close() + return err + } + if _, err := io.Copy(out, rc); err != nil { + rc.Close() + out.Close() + return err + } + rc.Close() + out.Close() + } + return nil +} + +func extractTarGz(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + tr := tar.NewReader(gzr) + return extractTarFromReader(tr, destDir) +} + +func extractTar(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + tr := tar.NewReader(f) + return extractTarFromReader(tr, destDir) +} + +// extractTarFromReader contains logic common to extracting entries from a +// tar.Reader and is used by both extractTarGz and extractTar to avoid +// duplicated code (golangci-lint: dupl). +func extractTarFromReader(tr *tar.Reader, destDir string) error { + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name)) + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) && + target != filepath.Clean(destDir) { + return fmt.Errorf("path traversal detected: %s", hdr.Name) + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + } + } + return nil +} + +func findBinaryInDir(dir, programName string) (string, error) { + wanted := []string{programName} + if runtime.GOOS == "windows" { + wanted = append([]string{programName + ".exe"}, wanted...) + } else { + // also accept programs with .exe in archives targeting windows + wanted = append(wanted, programName+".exe") + } + + var found string + if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || found != "" { + return err + } + if d.IsDir() { + return nil + } + base := filepath.Base(p) + for _, w := range wanted { + if base == w { + found = p + return io.EOF // use EOF to stop walking early + } + } + return nil + }); err != nil && err != io.EOF { + return "", err + } + if found == "" { + return "", fmt.Errorf("binary %q not found in archive", programName) + } + return found, nil +} + +// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease. +func NewUpdateCommand(binaryName string) *cobra.Command { + var urlStr, platform, arch string + cmd := &cobra.Command{ + Use: "update", + Short: "Check and apply updates from GitHub releases", + RunE: func(cmd *cobra.Command, args []string) error { + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + } + fmt.Printf("Current version: %s\n", config.FormatVersion()) + if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil { + return err + } + fmt.Println("Update applied; restart to use the new version.") + return nil + }, + } + cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page") + cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)") + cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)") + return cmd +} diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go new file mode 100644 index 000000000..ff75432e4 --- /dev/null +++ b/pkg/updater/updater_test.go @@ -0,0 +1,97 @@ +package updater + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// matchesMagic checks whether the file at path looks like a platform binary +// by inspecting magic bytes (ELF for linux, MZ for windows). +func matchesMagic(path, platform string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + buf := make([]byte, 4) + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return false, err + } + if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' { + return strings.Contains(platform, "linux"), nil + } + if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' { + return strings.Contains(platform, "windows"), nil + } + return false, nil +} + +// TestDownloadAndExtractRelease_RealPlatforms downloads the latest release +// asset for multiple platform/arch combos and inspects the extracted +// artifacts to ensure a binary-like file is present. This is a network test +// and is skipped in short mode. +func TestDownloadAndExtractRelease_RealPlatforms(t *testing.T) { + if testing.Short() { + t.Skip("skipping network tests in short mode") + } + + combos := []struct{ platform, arch string }{ + {"linux", "amd64"}, + {"linux", "arm64"}, + {"windows", "amd64"}, + {"windows", "arm64"}, + } + + apiURL := GetProdReleaseAPIURL() + for _, c := range combos { + t.Run(c.platform+"_"+c.arch, func(t *testing.T) { + assetURL, checksum, err := findAssetInfo(apiURL, c.platform, c.arch) + if err != nil { + // If no checksum could be located for this asset, skip this + // combo rather than failing — we require signed/checksummed + // releases for real-network tests. + t.Skipf("skipping %s/%s: %v", c.platform, c.arch, err) + } + t.Logf("asset URL: %s checksum: %s", assetURL, checksum) + + // Pass the release API URL (not the direct asset URL) so + // DownloadAndExtractRelease can locate and verify the asset. + dir, err := DownloadAndExtractRelease(apiURL, c.platform, c.arch) + if err != nil { + t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", c.platform, c.arch, err) + } + defer os.RemoveAll(dir) + + var found bool + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if info.Size() < 64 { + return nil + } + ok, err := matchesMagic(path, c.platform) + if err != nil { + return err + } + if ok { + found = true + t.Logf("found artifact: %s (size=%d)", path, info.Size()) + // continue walking to list all + } + return nil + }) + if !found { + t.Fatalf("no binary-like artifact found for %s/%s", c.platform, c.arch) + } + }) + } +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 3823fe08c..c6781baf1 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -81,6 +81,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) + // Self-update endpoint (requires dashboard auth) + h.registerUpdateRoutes(mux) + // Runtime build/version metadata h.registerVersionRoutes(mux) diff --git a/web/backend/api/update.go b/web/backend/api/update.go new file mode 100644 index 000000000..2ba862631 --- /dev/null +++ b/web/backend/api/update.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/sipeed/picoclaw/pkg/updater" +) + +// registerUpdateRoutes registers the self-update endpoint. +func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/update", h.handleUpdate) +} + +type updateRequest struct { + URL string `json:"url,omitempty"` + Binary string `json:"binary,omitempty"` +} + +type updateResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"}) + return + } + + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + var req updateRequest + if err := dec.Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"}) + return + } + + binary := req.Binary + if binary == "" { + binary = "picoclaw-launcher" + } + + if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()}) + return + } + + _ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"}) +} From 9ac21c5908db82b4a879569c5dffaa03c8ee14fe Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 23:44:41 +0800 Subject: [PATCH 69/72] add missing recover panic in subturn.go (#2253) --- pkg/agent/subturn.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 82a2a2010..9447f1384 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -511,6 +511,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re // We use defer/recover to catch any unlikely channel panics if it were ever closed. defer func() { if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{ "parent_id": parentTS.turnID, "child_id": childID, From bbcfeaa361845c4e903b086b9067cdd105335460 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:50:29 +0800 Subject: [PATCH 70/72] feat(provider): add Venice AI support and update related documentation (#2238) * feat(provider): add Venice AI support and update related documentation * revert(asr): restore asr files to previous commit * feat(config): add Venice API base URL and local LM Studio configuration * fix(config): update Venice API base URL to correct endpoint --- config/config.example.json | 5 ++++ docs/providers.md | 2 ++ docs/zh/providers.md | 2 ++ pkg/config/defaults.go | 14 ++++++++++ pkg/providers/factory_provider.go | 3 +- pkg/providers/factory_provider_test.go | 29 ++++++++++++++++++++ pkg/providers/openai_compat/provider.go | 1 + pkg/providers/openai_compat/provider_test.go | 8 ++++++ 8 files changed, 63 insertions(+), 1 deletion(-) diff --git a/config/config.example.json b/config/config.example.json index 95fe24e0b..bedd543d7 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -48,6 +48,11 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "venice-uncensored", + "model": "venice/venice-uncensored", + "api_key": "your-venice-api-key" + }, { "model_name": "lmstudio-local", "model": "lmstudio/openai/gpt-oss-20b" diff --git a/docs/providers.md b/docs/providers.md index f45aa5f3b..b0dfa0bc8 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -16,6 +16,7 @@ | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | @@ -46,6 +47,7 @@ This design also enables **multi-agent support** with flexible provider selectio | Vendor | `model` Prefix | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | diff --git a/docs/zh/providers.md b/docs/zh/providers.md index 04b2f7a88..43c4f26db 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -15,6 +15,7 @@ | `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `venice` | LLM (Venice AI 直连) | [venice.ai](https://venice.ai) | | `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | @@ -44,6 +45,7 @@ | 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | | ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | +| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 6eac5d8b9..a9a107975 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -185,6 +185,13 @@ func DefaultConfig() *Config { APIBase: "https://api.deepseek.com/v1", }, + // Venice AI - https://venice.ai + { + ModelName: "venice-uncensored", + Model: "venice/venice-uncensored", + APIBase: "https://api.venice.ai/api/v1", + }, + // Google Gemini - https://ai.google.dev/ { ModelName: "gemini-2.0-flash", @@ -335,6 +342,13 @@ func DefaultConfig() *Config { APIBase: "http://localhost:8000/v1", }, + // LM Studio (local) - http://localhost:1234 + { + ModelName: "lmstudio-local", + Model: "lmstudio/openai/gpt-oss-20b", + APIBase: "http://localhost:1234/v1", + }, + // Azure OpenAI - https://portal.azure.com // model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 16b2ead10..fb5191bf8 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -24,6 +24,7 @@ type protocolMeta struct { var protocolMetaByName = map[string]protocolMeta{ "openai": {defaultAPIBase: "https://api.openai.com/v1"}, + "venice": {defaultAPIBase: "https://api.venice.ai/api/v1"}, "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, @@ -209,7 +210,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return provider, modelID, nil - case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 588b81650..e2eafb934 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { protocol string }{ {"openai", "openai"}, + {"venice", "venice"}, {"groq", "groq"}, {"novita", "novita"}, {"openrouter", "openrouter"}, @@ -160,6 +161,12 @@ func TestGetDefaultAPIBase_LMStudio(t *testing.T) { } } +func TestGetDefaultAPIBase_Venice(t *testing.T) { + if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -362,6 +369,28 @@ func TestCreateProviderFromConfig_Mimo(t *testing.T) { } } +func TestCreateProviderFromConfig_Venice(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-venice", + Model: "venice/venice-uncensored", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "venice-uncensored" { + t.Errorf("modelID = %q, want %q", modelID, "venice-uncensored") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + func TestGetDefaultAPIBase_Mimo(t *testing.T) { if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1") diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index aa9473731..4ff42506f 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -44,6 +44,7 @@ const defaultRequestTimeout = common.DefaultRequestTimeout var stripModelPrefixProviders = map[string]struct{}{ "litellm": {}, + "venice": {}, "moonshot": {}, "nvidia": {}, "groq": {}, diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 823b0ff28..30aa76eb3 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -479,6 +479,11 @@ func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) { input: "lmstudio/openai/gpt-oss-20b", wantModel: "openai/gpt-oss-20b", }, + { + name: "strips venice prefix", + input: "venice/venice-uncensored", + wantModel: "venice-uncensored", + }, { name: "strips deepseek prefix", input: "deepseek/deepseek-chat", @@ -587,6 +592,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" { t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b") } + if got := normalizeModel("venice/venice-uncensored", "https://api.venice.ai/api/v1"); got != "venice-uncensored" { + t.Fatalf("normalizeModel(venice) = %q, want %q", got, "venice-uncensored") + } if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } From 2973b30ad7029eacc4665216c4cfcebc3626c722 Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 23:56:46 +0800 Subject: [PATCH 71/72] implement create dmg for macOS 10.11 & above (#2252) --- .github/workflows/create_dmg.yml | 62 ++++++++++++++++++++++++++++++++ Makefile | 17 +++++---- scripts/build-macos-app.sh | 22 ++++++------ 3 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/create_dmg.yml diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml new file mode 100644 index 000000000..b47247fc2 --- /dev/null +++ b/.github/workflows/create_dmg.yml @@ -0,0 +1,62 @@ +name: Create macOS DMG +on: + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.arch }} + runs-on: macos-latest + strategy: + matrix: + # This creates two parallel jobs + arch: [arm64, amd64] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: main + + # 1. 安装指定版本的 Go (可选,但推荐) + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + # 2. 安装 pnpm + - name: Install pnpm + run: brew install pnpm + + # 3. 运行你的 Makefile 编译二进制文件 + - name: Build with Make + run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }} + + # 4. 签名 + - name: Ad-hoc Sign + run: codesign --force --deep --sign - "build/PicoClaw Launcher.app" + + # 5. 安装打包工具 + - name: Install create-dmg + run: brew install create-dmg + + # 6. 执行打包命令 + - name: Create DMG + run: | + mkdir -p dist + create-dmg \ + --volname "PicoClaw Installer" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "PicoClaw Launcher.app" 200 190 \ + --hide-extension "PicoClaw Launcher.app" \ + --app-drop-link 600 185 \ + "dist/picoclaw-${{ matrix.arch }}.dmg" \ + "build/PicoClaw Launcher.app" + + # 6. 上传文件到 GitHub Artifacts (供你下载) + - name: Upload DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg-${{ matrix.arch }} + path: dist/*.dmg \ No newline at end of file diff --git a/Makefile b/Makefile index 992182775..21d8bdeac 100644 --- a/Makefile +++ b/Makefile @@ -93,13 +93,13 @@ ifeq ($(UNAME_S),Linux) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin - WEB_GO=CGO_ENABLED=1 go + WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) - ARCH=amd64 + ARCH?=amd64 else ifeq ($(UNAME_M),arm64) - ARCH=arm64 + ARCH?=arm64 else - ARCH=$(UNAME_M) + ARCH?=$(UNAME_M) endif else PLATFORM=$(UNAME_S) @@ -122,7 +122,7 @@ generate: build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) + @GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -130,7 +130,7 @@ build: generate build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(MAKE) -C web build \ + @GOARCH=${ARCH} $(MAKE) -C web build \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \ WEB_GO='$(WEB_GO)' \ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ @@ -324,14 +324,13 @@ docker-clean: ## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) -build-macos-app: +build-macos-app:build-launcher @echo "Building macOS .app bundle..." @if [ "$(UNAME_S)" != "Darwin" ]; then \ echo "Error: This target is only available on macOS"; \ exit 1; \ fi - @cd web && $(MAKE) build && cd .. - @./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH) + @./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH) @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" ## help: Show this help message diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 76cc72938..df2100aec 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then exit 1 fi +LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}" +EXECUTABLE="picoclaw-${EXECUTABLE}" echo "executable: $EXECUTABLE" APP_NAME="PicoClaw Launcher" @@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES" # Copy executable echo "Copying executable..." -if [ -f "./web/build/${APP_EXECUTABLE}" ]; then - cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/" +if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then + cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}" else - echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first." - echo "Run: make build in web dir" + echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first." + echo "Run: make build-launcher" exit 1 fi -if [ -f "./build/picoclaw" ]; then - cp "./build/picoclaw" "${APP_MACOS}/" +if [ -f "./build/${EXECUTABLE}" ]; then + cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw" else - echo "Error: ./build/picoclaw not found. Please build the main file first." + echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first." echo "Run: make build" exit 1 fi @@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF' NSSupportsAutomaticGraphicsSwitching - LSRequiresCarbon - LSUIElement - 1 + + LSMinimumSystemVersion + 10.11 EOF From 7eba27c3c464ff186eb623b7b8b9878c833b8b93 Mon Sep 17 00:00:00 2001 From: Liu Yuan Date: Thu, 2 Apr 2026 00:08:15 +0800 Subject: [PATCH 72/72] feat: add ContextManager abstraction for pluggable context management (#2203) - Define ContextManager interface with Assemble/Compact/Ingest methods - Implement legacyContextManager wrapping existing summarization logic - Wire Assemble (before BuildMessages), Compact (post-turn + overflow), and Ingest (after message persistence) into agent loop - Add ContextManager config field and factory registry with config passthrough - Remove old maybeSummarize/summarizeSession/summarizeBatch/etc from loop.go - All existing tests pass with default (legacy) config Co-authored-by: Liu Yuan --- pkg/agent/context_legacy.go | 379 +++++++++++++++ pkg/agent/context_manager.go | 89 ++++ pkg/agent/context_manager_test.go | 764 ++++++++++++++++++++++++++++++ pkg/agent/eventbus_test.go | 5 +- pkg/agent/events.go | 2 + pkg/agent/loop.go | 444 ++++------------- pkg/agent/turn.go | 18 + pkg/config/config.go | 34 +- 8 files changed, 1354 insertions(+), 381 deletions(-) create mode 100644 pkg/agent/context_legacy.go create mode 100644 pkg/agent/context_manager.go create mode 100644 pkg/agent/context_manager_test.go diff --git a/pkg/agent/context_legacy.go b/pkg/agent/context_legacy.go new file mode 100644 index 000000000..23402460e --- /dev/null +++ b/pkg/agent/context_legacy.go @@ -0,0 +1,379 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// legacyContextManager wraps the existing summarization/compression logic +// as a ContextManager implementation. It is the default when no other +// ContextManager is configured. +type legacyContextManager struct { + al *AgentLoop + summarizing sync.Map // dedup for async Compact (post-turn) +} + +func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + // Legacy: read history from session, return as-is. + // Budget enforcement happens in BuildMessages caller via + // isOverContextBudget + forceCompression. + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return &AssembleResponse{}, nil + } + history := agent.Sessions.GetHistory(req.SessionKey) + summary := agent.Sessions.GetSummary(req.SessionKey) + return &AssembleResponse{ + History: history, + Summary: summary, + }, nil +} + +func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error { + switch req.Reason { + case ContextCompressReasonProactive, ContextCompressReasonRetry: + // Sync emergency compression — budget exceeded. + if result, ok := m.forceCompression(req.SessionKey); ok { + m.al.emitEvent( + EventKindContextCompress, + m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"), + ContextCompressPayload{ + Reason: req.Reason, + DroppedMessages: result.DroppedMessages, + RemainingMessages: result.RemainingMessages, + }, + ) + } + case ContextCompressReasonSummarize: + m.maybeSummarize(req.SessionKey) + } + return nil +} + +func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error { + // Legacy: no-op. Messages are persisted by Sessions JSONL. + return nil +} + +// maybeSummarize triggers summarization if the session history exceeds thresholds. +// It runs asynchronously in a goroutine. +func (m *legacyContextManager) maybeSummarize(sessionKey string) { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return + } + + newHistory := agent.Sessions.GetHistory(sessionKey) + tokenEstimate := m.estimateTokens(newHistory) + threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 + + if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { + summarizeKey := agent.ID + ":" + sessionKey + if _, loading := m.summarizing.LoadOrStore(summarizeKey, true); !loading { + go func() { + defer m.summarizing.Delete(summarizeKey) + defer func() { + if r := recover(); r != nil { + logger.WarnCF("agent", "Summarization panic recovered", map[string]any{ + "session_key": sessionKey, + "panic": r, + }) + } + }() + logger.Debug("Memory threshold reached. Optimizing conversation history...") + m.summarizeSession(agent, sessionKey) + }() + } + } +} + +type compressionResult struct { + DroppedMessages int + RemainingMessages int +} + +// forceCompression aggressively reduces context when the limit is hit. +// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response +// cycle, as defined in #1316), so tool-call sequences are never split. +func (m *legacyContextManager) forceCompression(sessionKey string) (compressionResult, bool) { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return compressionResult{}, false + } + + history := agent.Sessions.GetHistory(sessionKey) + if len(history) <= 2 { + return compressionResult{}, false + } + + turns := parseTurnBoundaries(history) + var mid int + if len(turns) >= 2 { + mid = turns[len(turns)/2] + } else { + mid = findSafeBoundary(history, len(history)/2) + } + var keptHistory []providers.Message + if mid <= 0 { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == "user" { + keptHistory = []providers.Message{history[i]} + break + } + } + } else { + keptHistory = history[mid:] + } + + droppedCount := len(history) - len(keptHistory) + + existingSummary := agent.Sessions.GetSummary(sessionKey) + compressionNote := fmt.Sprintf( + "[Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, + ) + if existingSummary != "" { + compressionNote = existingSummary + "\n\n" + compressionNote + } + agent.Sessions.SetSummary(sessionKey, compressionNote) + + agent.Sessions.SetHistory(sessionKey, keptHistory) + agent.Sessions.Save(sessionKey) + + logger.WarnCF("agent", "Forced compression executed", map[string]any{ + "session_key": sessionKey, + "dropped_msgs": droppedCount, + "new_count": len(keptHistory), + }) + + return compressionResult{ + DroppedMessages: droppedCount, + RemainingMessages: len(keptHistory), + }, true +} + +func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + history := agent.Sessions.GetHistory(sessionKey) + summary := agent.Sessions.GetSummary(sessionKey) + + if len(history) <= 4 { + return + } + + safeCut := findSafeBoundary(history, len(history)-4) + if safeCut <= 0 { + return + } + keepCount := len(history) - safeCut + toSummarize := history[:safeCut] + + maxMessageTokens := agent.ContextWindow / 2 + validMessages := make([]providers.Message, 0) + omitted := false + + for _, msg := range toSummarize { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + msgTokens := len(msg.Content) / 2 + if msgTokens > maxMessageTokens { + omitted = true + continue + } + validMessages = append(validMessages, msg) + } + + if len(validMessages) == 0 { + return + } + + const ( + maxSummarizationMessages = 10 + llmMaxRetries = 3 + ) + + var finalSummary string + if len(validMessages) > maxSummarizationMessages { + mid := len(validMessages) / 2 + mid = m.findNearestUserMessage(validMessages, mid) + + part1 := validMessages[:mid] + part2 := validMessages[mid:] + + s1, _ := m.summarizeBatch(ctx, agent, part1, "") + s2, _ := m.summarizeBatch(ctx, agent, part2, "") + + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, s2, + ) + + resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) + if err == nil && resp.Content != "" { + finalSummary = resp.Content + } else { + finalSummary = s1 + " " + s2 + } + } else { + finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary) + } + + if omitted && finalSummary != "" { + finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" + } + + if finalSummary != "" { + agent.Sessions.SetSummary(sessionKey, finalSummary) + agent.Sessions.TruncateHistory(sessionKey, keepCount) + agent.Sessions.Save(sessionKey) + m.al.emitEvent( + EventKindSessionSummarize, + m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"), + SessionSummarizePayload{ + SummarizedMessages: len(validMessages), + KeptMessages: keepCount, + SummaryLen: len(finalSummary), + OmittedOversized: omitted, + }, + ) + } +} + +func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int { + originalMid := mid + + for mid > 0 && messages[mid].Role != "user" { + mid-- + } + + if messages[mid].Role == "user" { + return mid + } + + mid = originalMid + for mid < len(messages) && messages[mid].Role != "user" { + mid++ + } + + if mid < len(messages) { + return mid + } + + return originalMid +} + +func (m *legacyContextManager) retryLLMCall( + ctx context.Context, + agent *AgentInstance, + prompt string, + maxRetries int, +) (*providers.LLMResponse, error) { + const llmTemperature = 0.3 + + var resp *providers.LLMResponse + var err error + + for attempt := 0; attempt < maxRetries; attempt++ { + m.al.activeRequests.Add(1) + resp, err = func() (*providers.LLMResponse, error) { + defer m.al.activeRequests.Done() + return agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": llmTemperature, + "prompt_cache_key": agent.ID, + }, + ) + }() + + if err == nil && resp != nil && resp.Content != "" { + return resp, nil + } + if attempt < maxRetries-1 { + time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) + } + } + + return resp, err +} + +func (m *legacyContextManager) summarizeBatch( + ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + existingSummary string, +) (string, error) { + const ( + llmMaxRetries = 3 + fallbackMinContentLength = 200 + fallbackMaxContentPercent = 10 + ) + + var sb strings.Builder + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") + if existingSummary != "" { + sb.WriteString("Existing context: ") + sb.WriteString(existingSummary) + sb.WriteString("\n") + } + sb.WriteString("\nCONVERSATION:\n") + for _, msg := range batch { + fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content) + } + prompt := sb.String() + + response, err := m.retryLLMCall(ctx, agent, prompt, llmMaxRetries) + if err == nil && response.Content != "" { + return strings.TrimSpace(response.Content), nil + } + + var fallback strings.Builder + fallback.WriteString("Conversation summary: ") + for i, msg := range batch { + if i > 0 { + fallback.WriteString(" | ") + } + content := strings.TrimSpace(msg.Content) + runes := []rune(content) + if len(runes) == 0 { + fallback.WriteString(fmt.Sprintf("%s: ", msg.Role)) + continue + } + + keepLength := len(runes) * fallbackMaxContentPercent / 100 + if keepLength < fallbackMinContentLength { + keepLength = fallbackMinContentLength + } + if keepLength > len(runes) { + keepLength = len(runes) + } + + content = string(runes[:keepLength]) + if keepLength < len(runes) { + content += "..." + } + fallback.WriteString(fmt.Sprintf("%s: %s", msg.Role, content)) + } + return fallback.String(), nil +} + +func (m *legacyContextManager) estimateTokens(messages []providers.Message) int { + total := 0 + for _, msg := range messages { + total += estimateMessageTokens(msg) + } + return total +} diff --git a/pkg/agent/context_manager.go b/pkg/agent/context_manager.go new file mode 100644 index 000000000..cc8904ccf --- /dev/null +++ b/pkg/agent/context_manager.go @@ -0,0 +1,89 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ContextManager manages conversation context via a pluggable strategy. +// Exactly ONE ContextManager is active per AgentLoop, selected by config. +// The default ("legacy") preserves current summarization behavior. +type ContextManager interface { + // Assemble builds budget-aware context from the ContextManager's own storage. + // Called before BuildMessages. Returns assembled messages ready for LLM. + Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) + + // Compact compresses conversation history. + // Called after turn completes (may be async internally) and on context overflow (sync). + Compact(ctx context.Context, req *CompactRequest) error + + // Ingest records a message into the ContextManager's own storage. + // Called after each message is persisted to session JSONL. + Ingest(ctx context.Context, req *IngestRequest) error +} + +// AssembleRequest is the input to Assemble. +type AssembleRequest struct { + SessionKey string // session identifier + Budget int // context window in tokens + MaxTokens int // max response tokens +} + +// AssembleResponse is the output of Assemble. +type AssembleResponse struct { + History []providers.Message // assembled conversation history for BuildMessages + Summary string // conversation summary embedded into system prompt by BuildMessages +} + +// CompactRequest is the input to Compact. +type CompactRequest struct { + SessionKey string // session identifier + Reason ContextCompressReason // proactive_budget | llm_retry | summarize +} + +// IngestRequest is the input to Ingest. +type IngestRequest struct { + SessionKey string // session identifier + Message providers.Message // the message just persisted +} + +// ContextManagerFactory constructs a ContextManager from config. +// al provides access to the AgentLoop's runtime resources (provider, model, workspace, etc.) +// cfg is the raw JSON configuration from config.json (may be nil). +type ContextManagerFactory func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) + +var ( + cmRegistryMu sync.RWMutex + cmRegistry = map[string]ContextManagerFactory{} +) + +// RegisterContextManager registers a named ContextManager factory. +func RegisterContextManager(name string, factory ContextManagerFactory) error { + if name == "" { + return fmt.Errorf("context manager name is required") + } + if factory == nil { + return fmt.Errorf("context manager %q factory is nil", name) + } + + cmRegistryMu.Lock() + defer cmRegistryMu.Unlock() + + if _, exists := cmRegistry[name]; exists { + return fmt.Errorf("context manager %q is already registered", name) + } + cmRegistry[name] = factory + return nil +} + +func lookupContextManager(name string) (ContextManagerFactory, bool) { + cmRegistryMu.RLock() + defer cmRegistryMu.RUnlock() + + f, ok := cmRegistry[name] + return f, ok +} diff --git a/pkg/agent/context_manager_test.go b/pkg/agent/context_manager_test.go new file mode 100644 index 000000000..6bde5e1a9 --- /dev/null +++ b/pkg/agent/context_manager_test.go @@ -0,0 +1,764 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// --------------------------------------------------------------------------- +// Factory registry tests +// --------------------------------------------------------------------------- + +func TestRegisterContextManager_Success(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("test_cm", factory); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + f, ok := lookupContextManager("test_cm") + if !ok { + t.Fatal("expected factory to be registered") + } + if f == nil { + t.Fatal("expected non-nil factory") + } +} + +func TestRegisterContextManager_EmptyName(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + err := RegisterContextManager("", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + }) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRegisterContextManager_NilFactory(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + err := RegisterContextManager("nil_factory", nil) + if err == nil { + t.Fatal("expected error for nil factory") + } + if !strings.Contains(err.Error(), "factory is nil") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRegisterContextManager_Duplicate(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("dup_cm", factory); err != nil { + t.Fatalf("first registration failed: %v", err) + } + err := RegisterContextManager("dup_cm", factory) + if err == nil { + t.Fatal("expected error for duplicate registration") + } + if !strings.Contains(err.Error(), "already registered") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLookupContextManager_Unknown(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + _, ok := lookupContextManager("nonexistent") + if ok { + t.Fatal("expected lookup to fail for unknown name") + } +} + +// --------------------------------------------------------------------------- +// resolveContextManager tests +// --------------------------------------------------------------------------- + +func TestResolveContextManager_Default(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "", // default → legacy + }, + }, + } + al := newCMTestAgentLoop(cfg) + + cm := al.contextManager + if cm == nil { + t.Fatal("expected non-nil context manager") + } + if _, ok := cm.(*legacyContextManager); !ok { + t.Fatalf("expected *legacyContextManager, got %T", cm) + } +} + +func TestResolveContextManager_ExplicitLegacy(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "legacy", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected *legacyContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_UnknownFallsBackToLegacy(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "unknown_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected fallback to *legacyContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_RegisteredFactory(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("custom_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "custom_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*noopContextManager); !ok { + t.Fatalf("expected *noopContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_FactoryError(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return nil, os.ErrPermission + } + if err := RegisterContextManager("broken_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "broken_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + // Should fall back to legacy when factory returns error + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected fallback to *legacyContextManager on factory error, got %T", al.contextManager) + } +} + +// --------------------------------------------------------------------------- +// Legacy Assemble tests +// --------------------------------------------------------------------------- + +func TestLegacyAssemble_Passthrough(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + } + agent.Sessions.SetHistory("test-session", history) + + resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "test-session", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.History) != len(history) { + t.Fatalf("expected %d messages, got %d", len(history), len(resp.History)) + } + for i, msg := range resp.History { + if msg.Content != history[i].Content || msg.Role != history[i].Role { + t.Fatalf("message %d mismatch: want %+v, got %+v", i, history[i], msg) + } + } +} + +func TestLegacyAssemble_EmptyHistory(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "test-session", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.History) != 0 { + t.Fatalf("expected empty messages, got %d", len(resp.History)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Compact overflow tests +// --------------------------------------------------------------------------- + +func TestLegacyCompact_Overflow(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "msg 1"}, + {Role: "assistant", Content: "resp 1"}, + {Role: "user", Content: "msg 2"}, + {Role: "assistant", Content: "resp 2"}, + {Role: "user", Content: "msg 3"}, + } + defaultAgent.Sessions.SetHistory("session-overflow", history) + + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-overflow", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // After overflow compression, history should be shorter + newHistory := defaultAgent.Sessions.GetHistory("session-overflow") + if len(newHistory) >= len(history) { + t.Fatalf("expected compressed history, got %d messages (was %d)", len(newHistory), len(history)) + } + + // Summary should contain compression note + summary := defaultAgent.Sessions.GetSummary("session-overflow") + if !strings.Contains(summary, "Emergency compression") { + t.Fatalf("expected compression note in summary, got %q", summary) + } + + // Event should carry the proactive reason + events := collectEventStream(sub.C) + compressEvt, ok := findEvent(events, EventKindContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonRetry { + t.Fatalf("expected retry reason, got %q", payload.Reason) + } +} + +func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "msg 1"}, + {Role: "assistant", Content: "resp 1"}, + {Role: "user", Content: "msg 2"}, + {Role: "assistant", Content: "resp 2"}, + {Role: "user", Content: "msg 3"}, + } + defaultAgent.Sessions.SetHistory("session-proactive", history) + + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-proactive", + Reason: ContextCompressReasonProactive, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + events := collectEventStream(sub.C) + compressEvt, ok := findEvent(events, EventKindContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonProactive { + t.Fatalf("expected proactive reason, got %q", payload.Reason) + } +} + +func TestLegacyCompact_Overflow_TooShortToCompress(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "only one"}, + } + defaultAgent.Sessions.SetHistory("session-tiny", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-tiny", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // History should be unchanged (too short to compress) + newHistory := defaultAgent.Sessions.GetHistory("session-tiny") + if len(newHistory) != len(history) { + t.Fatalf("expected history unchanged, got %d messages (was %d)", len(newHistory), len(history)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Compact post-turn tests +// --------------------------------------------------------------------------- + +func TestLegacyCompact_PostTurn_BelowThreshold(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Small history, below summarization thresholds + history := []providers.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + } + defaultAgent.Sessions.SetHistory("session-small", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-small", + Reason: ContextCompressReasonSummarize, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // History should remain unchanged + newHistory := defaultAgent.Sessions.GetHistory("session-small") + if len(newHistory) != len(history) { + t.Fatalf("expected unchanged history, got %d messages (was %d)", len(newHistory), len(history)) + } +} + +func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextWindow: 8000, + SummarizeMessageThreshold: 2, + SummarizeTokenPercent: 75, + }, + }, + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"}) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // 6 messages > threshold of 2 + history := []providers.Message{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q2"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "q3"}, + {Role: "assistant", Content: "a3"}, + } + defaultAgent.Sessions.SetHistory("session-threshold", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-threshold", + Reason: ContextCompressReasonSummarize, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Wait for async summarization to complete via event + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + waitForEvent(t, sub.C, 5*time.Second, func(evt Event) bool { + return evt.Kind == EventKindSessionSummarize + }) + + newHistory := defaultAgent.Sessions.GetHistory("session-threshold") + if len(newHistory) >= len(history) { + t.Fatalf("expected summarization to reduce history from %d messages, got %d", len(history), len(newHistory)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Ingest tests +// --------------------------------------------------------------------------- + +func TestLegacyIngest_NoOp(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + err := al.contextManager.Ingest(context.Background(), &IngestRequest{ + SessionKey: "session-ingest", + Message: providers.Message{Role: "user", Content: "test"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Mock ContextManager — verifies dispatch through AgentLoop +// --------------------------------------------------------------------------- + +func TestAgentLoop_UsesCustomContextManager(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("tracking_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "tracking_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + // Verify the mock was installed + if al.contextManager != mock { + t.Fatalf("expected mock context manager, got %T", al.contextManager) + } + + // Direct method calls + _, err := mock.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "s1", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("Assemble error: %v", err) + } + if mock.assembleCalls.Load() != 1 { + t.Fatalf("expected 1 assemble call, got %d", mock.assembleCalls.Load()) + } + + err = mock.Compact(context.Background(), &CompactRequest{ + SessionKey: "s1", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("Compact error: %v", err) + } + if mock.compactCalls.Load() != 1 { + t.Fatalf("expected 1 compact call, got %d", mock.compactCalls.Load()) + } + + err = mock.Ingest(context.Background(), &IngestRequest{ + SessionKey: "s1", + Message: providers.Message{Role: "user", Content: "test"}, + }) + if err != nil { + t.Fatalf("Ingest error: %v", err) + } + if mock.ingestCalls.Load() != 1 { + t.Fatalf("expected 1 ingest call, got %d", mock.ingestCalls.Load()) + } +} + +func TestIngestCalledDuringTurn(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("ingest_track_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "ingest_track_cm", + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"}) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Run a turn — ingestMessage is called for user message and final assistant message + _, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + SessionKey: "session-ingest-turn", + Channel: "cli", + ChatID: "direct", + UserMessage: "test ingest", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + // Should have at least 2 ingest calls: user message + final assistant message + if mock.ingestCalls.Load() < 2 { + t.Fatalf("expected >= 2 ingest calls during turn, got %d", mock.ingestCalls.Load()) + } +} + +// --------------------------------------------------------------------------- +// forceCompression edge cases (via legacy Compact) +// --------------------------------------------------------------------------- + +func TestLegacyCompact_Overflow_SingleTurnKeepsLastUserMessage(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // History with only 2 messages — forceCompression should still handle it + history := []providers.Message{ + {Role: "user", Content: "first question"}, + {Role: "assistant", Content: "first answer"}, + } + defaultAgent.Sessions.SetHistory("session-2msg", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-2msg", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + newHistory := defaultAgent.Sessions.GetHistory("session-2msg") + // With 2 messages, forceCompression returns false (len <= 2), so no compression + if len(newHistory) != len(history) { + t.Fatalf("expected no compression for 2-message history, got %d", len(newHistory)) + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// noopContextManager is a minimal ContextManager that does nothing. +type noopContextManager struct{} + +func (m *noopContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + return &AssembleResponse{}, nil +} +func (m *noopContextManager) Compact(_ context.Context, _ *CompactRequest) error { return nil } +func (m *noopContextManager) Ingest(_ context.Context, _ *IngestRequest) error { return nil } + +// trackingContextManager tracks call counts for each method. +type trackingContextManager struct { + assembleCalls atomic.Int64 + compactCalls atomic.Int64 + ingestCalls atomic.Int64 + mu sync.Mutex + lastAssemble *AssembleRequest + lastCompact *CompactRequest + lastIngest *IngestRequest +} + +func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + m.assembleCalls.Add(1) + m.mu.Lock() + m.lastAssemble = req + m.mu.Unlock() + return &AssembleResponse{}, nil +} + +func (m *trackingContextManager) Compact(_ context.Context, req *CompactRequest) error { + m.compactCalls.Add(1) + m.mu.Lock() + m.lastCompact = req + m.mu.Unlock() + return nil +} + +func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) error { + m.ingestCalls.Add(1) + m.mu.Lock() + m.lastIngest = req + m.mu.Unlock() + return nil +} + +// resetCMRegistry clears the global factory registry and returns a cleanup +// function that restores the original state after the test. +func resetCMRegistry() func() { + cmRegistryMu.Lock() + original := make(map[string]ContextManagerFactory, len(cmRegistry)) + for k, v := range cmRegistry { + original[k] = v + } + cmRegistry = make(map[string]ContextManagerFactory) + cmRegistryMu.Unlock() + + return func() { + cmRegistryMu.Lock() + cmRegistry = original + cmRegistryMu.Unlock() + } +} + +func testConfig(t *testing.T) *config.Config { + t.Helper() + return &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } +} + +func newCMTestAgentLoop(cfg *config.Config) *AgentLoop { + msgBus := bus.NewMessageBus() + return NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "test"}) +} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 19a1ea9eb..2785d70a5 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -472,8 +472,9 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { sub := al.SubscribeEvents(16) defer al.UnsubscribeEvents(sub.ID) - turnScope := al.newTurnEventScope(defaultAgent.ID, "session-1") - al.summarizeSession(defaultAgent, "session-1", turnScope) + // Use legacyContextManager's summarizeSession via contextManager interface + lcm := &legacyContextManager{al: al} + lcm.summarizeSession(defaultAgent, "session-1") events := collectEventStream(sub.C) summaryEvt, ok := findEvent(events, EventKindSessionSummarize) diff --git a/pkg/agent/events.go b/pkg/agent/events.go index f4562b360..615eacf9f 100644 --- a/pkg/agent/events.go +++ b/pkg/agent/events.go @@ -167,6 +167,8 @@ const ( ContextCompressReasonProactive ContextCompressReason = "proactive_budget" // ContextCompressReasonRetry indicates compression during context-error retry handling. ContextCompressReasonRetry ContextCompressReason = "llm_retry" + // ContextCompressReasonSummarize indicates post-turn async summarization. + ContextCompressReasonSummarize ContextCompressReason = "summarize" ) // ContextCompressPayload describes a forced history compression. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 15535e138..624ff261b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -48,7 +48,7 @@ type AgentLoop struct { // Runtime state running atomic.Bool - summarizing sync.Map + contextManager ContextManager fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore @@ -137,13 +137,13 @@ func NewAgentLoop( registry: registry, state: stateManager, eventBus: eventBus, - summarizing: sync.Map{}, fallback: fallbackChain, cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), } al.hooks = NewHookManager(eventBus) configureHookManagerFromConfig(al.hooks, cfg) + al.contextManager = al.resolveContextManager() // Register shared tools to all agents (now that al is created) registerSharedTools(al, cfg, msgBus, registry, provider) @@ -1690,8 +1690,15 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er var history []providers.Message var summary string if !ts.opts.NoHistory { - history = ts.agent.Sessions.GetHistory(ts.sessionKey) - summary = ts.agent.Sessions.GetSummary(ts.sessionKey) + // ContextManager assembles budget-aware history and summary. + if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } } ts.captureRestorePoint(history, summary) @@ -1716,22 +1723,27 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", map[string]any{"session_key": ts.sessionKey}) - if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok { - al.emitEvent( - EventKindContextCompress, - ts.eventMeta("runTurn", "turn.context.compress"), - ContextCompressPayload{ - Reason: ContextCompressReasonProactive, - DroppedMessages: compression.DroppedMessages, - RemainingMessages: compression.RemainingMessages, - }, - ) - ts.refreshRestorePointFromSession(ts.agent) + if err := al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonProactive, + }); err != nil { + logger.WarnCF("agent", "Proactive compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + // Re-assemble from CM after compact. + if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary } - newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey) - newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) messages = ts.agent.ContextBuilder.BuildMessages( - newHistory, newSummary, ts.userMessage, + history, summary, ts.userMessage, ts.media, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., @@ -1753,6 +1765,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) } ts.recordPersistedMessage(rootMsg) + ts.ingestMessage(turnCtx, al, rootMsg) } activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) @@ -2096,23 +2109,27 @@ turnLoop: }) } - if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok { - al.emitEvent( - EventKindContextCompress, - ts.eventMeta("runTurn", "turn.context.compress"), - ContextCompressPayload{ - Reason: ContextCompressReasonRetry, - DroppedMessages: compression.DroppedMessages, - RemainingMessages: compression.RemainingMessages, - }, - ) - ts.refreshRestorePointFromSession(ts.agent) + if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonRetry, + }); compactErr != nil { + logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": compactErr.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + // Re-assemble from CM after compact. + if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); asmErr == nil && asmResp != nil { + history = asmResp.History + summary = asmResp.Summary } - - newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey) - newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) messages = ts.agent.ContextBuilder.BuildMessages( - newHistory, newSummary, "", + history, summary, "", nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., ) @@ -2285,6 +2302,7 @@ turnLoop: if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) ts.recordPersistedMessage(assistantMsg) + ts.ingestMessage(turnCtx, al, assistantMsg) } ts.setPhase(TurnPhaseTools) @@ -2624,6 +2642,7 @@ turnLoop: if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { @@ -2723,6 +2742,7 @@ turnLoop: if !ts.opts.NoHistory { ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) ts.recordPersistedMessage(summaryMsg) + ts.ingestMessage(turnCtx, al, summaryMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { turnStatus = TurnEndStatusError al.emitEvent( @@ -2737,7 +2757,7 @@ turnLoop: } } if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) + al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize}) } ts.setPhase(TurnPhaseCompleted) @@ -2792,6 +2812,7 @@ turnLoop: finalMsg := providers.Message{Role: "assistant", Content: finalContent} ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) ts.recordPersistedMessage(finalMsg) + ts.ingestMessage(turnCtx, al, finalMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { turnStatus = TurnEndStatusError al.emitEvent( @@ -2807,7 +2828,13 @@ turnLoop: } if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) + al.contextManager.Compact( + turnCtx, + &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + }, + ) } ts.setPhase(TurnPhaseCompleted) @@ -2886,103 +2913,28 @@ func (al *AgentLoop) selectCandidates( return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true } -// maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { - newHistory := agent.Sessions.GetHistory(sessionKey) - tokenEstimate := al.estimateTokens(newHistory) - threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 - - if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { - summarizeKey := agent.ID + ":" + sessionKey - if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { - go func() { - defer al.summarizing.Delete(summarizeKey) - logger.Debug("Memory threshold reached. Optimizing conversation history...") - al.summarizeSession(agent, sessionKey, turnScope) - }() - } +// resolveContextManager selects the ContextManager implementation based on config. +func (al *AgentLoop) resolveContextManager() ContextManager { + name := al.cfg.Agents.Defaults.ContextManager + if name == "" || name == "legacy" { + return &legacyContextManager{al: al} } -} - -type compressionResult struct { - DroppedMessages int - RemainingMessages int -} - -// forceCompression aggressively reduces context when the limit is hit. -// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response -// cycle, as defined in #1316), so tool-call sequences are never split. -// -// If the history is a single Turn with no safe split point, the function -// falls back to keeping only the most recent user message. This breaks -// Turn atomicity as a last resort to avoid a context-exceeded loop. -// -// Session history contains only user/assistant/tool messages — the system -// prompt is built dynamically by BuildMessages and is NOT stored here. -// The compression note is recorded in the session summary so that -// BuildMessages can include it in the next system prompt. -func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) { - history := agent.Sessions.GetHistory(sessionKey) - if len(history) <= 2 { - return compressionResult{}, false + factory, ok := lookupContextManager(name) + if !ok { + logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ + "name": name, + }) + return &legacyContextManager{al: al} } - - // Split at a Turn boundary so no tool-call sequence is torn apart. - // parseTurnBoundaries gives us the start of each Turn; we drop the - // oldest half of Turns and keep the most recent ones. - turns := parseTurnBoundaries(history) - var mid int - if len(turns) >= 2 { - mid = turns[len(turns)/2] - } else { - // Fewer than 2 Turns — fall back to message-level midpoint - // aligned to the nearest Turn boundary. - mid = findSafeBoundary(history, len(history)/2) + cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) + if err != nil { + logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ + "name": name, + "error": err.Error(), + }) + return &legacyContextManager{al: al} } - var keptHistory []providers.Message - if mid <= 0 { - // No safe Turn boundary — the entire history is a single Turn - // (e.g. one user message followed by a massive tool response). - // Keeping everything would leave the agent stuck in a context- - // exceeded loop, so fall back to keeping only the most recent - // user message. This breaks Turn atomicity as a last resort. - for i := len(history) - 1; i >= 0; i-- { - if history[i].Role == "user" { - keptHistory = []providers.Message{history[i]} - break - } - } - } else { - keptHistory = history[mid:] - } - - droppedCount := len(history) - len(keptHistory) - - // Record compression in the session summary so BuildMessages includes it - // in the system prompt. We do not modify history messages themselves. - existingSummary := agent.Sessions.GetSummary(sessionKey) - compressionNote := fmt.Sprintf( - "[Emergency compression dropped %d oldest messages due to context limit]", - droppedCount, - ) - if existingSummary != "" { - compressionNote = existingSummary + "\n\n" + compressionNote - } - agent.Sessions.SetSummary(sessionKey, compressionNote) - - agent.Sessions.SetHistory(sessionKey, keptHistory) - agent.Sessions.Save(sessionKey) - - logger.WarnCF("agent", "Forced compression executed", map[string]any{ - "session_key": sessionKey, - "dropped_msgs": droppedCount, - "new_count": len(keptHistory), - }) - - return compressionResult{ - DroppedMessages: droppedCount, - RemainingMessages: len(keptHistory), - }, true + return cm } // GetStartupInfo returns information about loaded tools and skills for logging. @@ -3074,247 +3026,13 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - history := agent.Sessions.GetHistory(sessionKey) - summary := agent.Sessions.GetSummary(sessionKey) - - // Keep the most recent Turns for continuity, aligned to a Turn boundary - // so that no tool-call sequence is split. - if len(history) <= 4 { - return - } - - safeCut := findSafeBoundary(history, len(history)-4) - if safeCut <= 0 { - return - } - keepCount := len(history) - safeCut - toSummarize := history[:safeCut] - - // Oversized Message Guard - maxMessageTokens := agent.ContextWindow / 2 - validMessages := make([]providers.Message, 0) - omitted := false - - for _, m := range toSummarize { - if m.Role != "user" && m.Role != "assistant" { - continue - } - msgTokens := len(m.Content) / 2 - if msgTokens > maxMessageTokens { - omitted = true - continue - } - validMessages = append(validMessages, m) - } - - if len(validMessages) == 0 { - return - } - - const ( - maxSummarizationMessages = 10 - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMaxContentLength = 200 - ) - - // Multi-Part Summarization - var finalSummary string - if len(validMessages) > maxSummarizationMessages { - mid := len(validMessages) / 2 - - mid = al.findNearestUserMessage(validMessages, mid) - - part1 := validMessages[:mid] - part2 := validMessages[mid:] - - s1, _ := al.summarizeBatch(ctx, agent, part1, "") - s2, _ := al.summarizeBatch(ctx, agent, part2, "") - - mergePrompt := fmt.Sprintf( - "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", - s1, - s2, - ) - - resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) - if err == nil && resp.Content != "" { - finalSummary = resp.Content - } else { - finalSummary = s1 + " " + s2 - } - } else { - finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) - } - - if omitted && finalSummary != "" { - finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" - } - - if finalSummary != "" { - agent.Sessions.SetSummary(sessionKey, finalSummary) - agent.Sessions.TruncateHistory(sessionKey, keepCount) - agent.Sessions.Save(sessionKey) - al.emitEvent( - EventKindSessionSummarize, - turnScope.meta(0, "summarizeSession", "turn.session.summarize"), - SessionSummarizePayload{ - SummarizedMessages: len(validMessages), - KeptMessages: keepCount, - SummaryLen: len(finalSummary), - OmittedOversized: omitted, - }, - ) - } -} - // findNearestUserMessage finds the nearest user message to the given index. // It searches backward first, then forward if no user message is found. -func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int { - originalMid := mid - - for mid > 0 && messages[mid].Role != "user" { - mid-- - } - - if messages[mid].Role == "user" { - return mid - } - - mid = originalMid - for mid < len(messages) && messages[mid].Role != "user" { - mid++ - } - - if mid < len(messages) { - return mid - } - - return originalMid -} - // retryLLMCall calls the LLM with retry logic. -func (al *AgentLoop) retryLLMCall( - ctx context.Context, - agent *AgentInstance, - prompt string, - maxRetries int, -) (*providers.LLMResponse, error) { - const ( - llmTemperature = 0.3 - ) - - var resp *providers.LLMResponse - var err error - - for attempt := 0; attempt < maxRetries; attempt++ { - al.activeRequests.Add(1) - resp, err = func() (*providers.LLMResponse, error) { - defer al.activeRequests.Done() - return agent.Provider.Chat( - ctx, - []providers.Message{{Role: "user", Content: prompt}}, - nil, - agent.Model, - map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": llmTemperature, - "prompt_cache_key": agent.ID, - }, - ) - }() - - if err == nil && resp != nil && resp.Content != "" { - return resp, nil - } - if attempt < maxRetries-1 { - time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) - } - } - - return resp, err -} - // summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch( - ctx context.Context, - agent *AgentInstance, - batch []providers.Message, - existingSummary string, -) (string, error) { - const ( - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMinContentLength = 200 - fallbackMaxContentPercent = 10 - ) - - var sb strings.Builder - sb.WriteString( - "Provide a concise summary of this conversation segment, preserving core context and key points.\n", - ) - if existingSummary != "" { - sb.WriteString("Existing context: ") - sb.WriteString(existingSummary) - sb.WriteString("\n") - } - sb.WriteString("\nCONVERSATION:\n") - for _, m := range batch { - fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) - } - prompt := sb.String() - - response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries) - if err == nil && response.Content != "" { - return strings.TrimSpace(response.Content), nil - } - - var fallback strings.Builder - fallback.WriteString("Conversation summary: ") - for i, m := range batch { - if i > 0 { - fallback.WriteString(" | ") - } - content := strings.TrimSpace(m.Content) - runes := []rune(content) - if len(runes) == 0 { - fallback.WriteString(fmt.Sprintf("%s: ", m.Role)) - continue - } - - keepLength := len(runes) * fallbackMaxContentPercent / 100 - if keepLength < fallbackMinContentLength { - keepLength = fallbackMinContentLength - } - - if keepLength > len(runes) { - keepLength = len(runes) - } - - content = string(runes[:keepLength]) - if keepLength < len(runes) { - content += "..." - } - fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content)) - } - return fallback.String(), nil -} - // estimateTokens estimates the number of tokens in a message list. // Counts Content, ToolCalls arguments, and ToolCallID metadata so that // tool-heavy conversations are not systematically undercounted. -func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - total := 0 - for _, m := range messages { - total += estimateMessageTokens(m) - } - return total -} - func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, diff --git a/pkg/agent/turn.go b/pkg/agent/turn.go index e4970c519..8f099ed1d 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" @@ -338,6 +339,23 @@ func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) { ts.captureRestorePoint(history, summary) } +// ingestMessage calls the ContextManager's Ingest method for a persisted message. +// Errors are logged but never block the turn. +func (ts *turnState) ingestMessage(ctx context.Context, al *AgentLoop, msg providers.Message) { + if al.contextManager == nil { + return + } + if err := al.contextManager.Ingest(ctx, &IngestRequest{ + SessionKey: ts.sessionKey, + Message: msg, + }); err != nil { + logger.WarnCF("agent", "Context manager ingest failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } +} + func (ts *turnState) restoreSession(agent *AgentInstance) error { ts.mu.RLock() history := append([]providers.Message(nil), ts.restorePointHistory...) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a11d1ab7..a35689bc1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -226,26 +226,28 @@ type ToolFeedbackConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` - SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` - MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` - SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" - SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` + SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" + SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` - SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker + SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker + ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` + ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB